diff --git a/package.json b/package.json
index 1ef2d24c..ad162b39 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"ls": "lerna ls",
"release:next": "npm run release -- --npm-tag=next",
"exec:rp": "lerna exec --scope @cra-express/redux-prefetcher --",
+ "exec:rr": "lerna exec --scope @cra-express/router-prefetcher --",
"exec:ul": "lerna exec --scope @cra-express/universal-loader --",
"ul:test": "npm run exec:ul -- npm t --"
},
diff --git a/packages/@cra-express/router-prefetcher/.babelrc b/packages/@cra-express/router-prefetcher/.babelrc
new file mode 100644
index 00000000..2b7bafa5
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["@babel/preset-env", "@babel/preset-react"]
+}
diff --git a/packages/@cra-express/router-prefetcher/.prettierrc b/packages/@cra-express/router-prefetcher/.prettierrc
new file mode 100644
index 00000000..1fb73bc2
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 2,
+ "singleQuote": true
+}
diff --git a/packages/@cra-express/router-prefetcher/README.md b/packages/@cra-express/router-prefetcher/README.md
new file mode 100644
index 00000000..fc3f90d3
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/README.md
@@ -0,0 +1,166 @@
+# @cra-express/router-prefetcher
+
+> Simple utility to map your routes and prefetch your data on server
+
+> You might want to wait for React Suspense, [demo](https://github.com/acdlite/suspense-ssr-demo/)
+
+> :warning: **Alpha** stage. API may change, don't use on production yet!
+
+## Prerequisites
+
+- React Router with array config
+- Promise support
+
+## Start
+
+```
+npm i @cra-express/router-prefetcher
+```
+
+## Setup and Usage
+
+- Add `{{SCRIPT}}` to public/index.html
+
+```html
+
+{{SCRIPT}}
+```
+
+```js
+// server/app.js
+
+import { createReactAppExpress } from '@cra-express/core';
+import { getInitialData } from '@cra-express/router-prefetcher';
+import routes from '../src/routes';
+const path = require('path');
+const React = require('react');
+const { StaticRouter } = require('react-router');
+
+const { default: App } = require('../src/App');
+const clientBuildPath = path.resolve(__dirname, '../client');
+let AppClass = App;
+let serverData;
+const app = createReactAppExpress({
+ clientBuildPath,
+ universalRender: handleUniversalRender,
+ onEndReplace(html) {
+ const state = store.getState();
+ return html.replace(
+ '{{SCRIPT}}',
+ ``
+ );
+ }
+});
+
+function handleUniversalRender(req, res) {
+ const context = {};
+ return getInitialData(req, res, routes)
+ .then(data => {
+ serverData = data;
+ const app = (
+
+
+
+ );
+ return app;
+ })
+ .catch(err => {
+ console.error(err);
+ res.send(500);
+ });
+}
+
+export default app;
+```
+
+```js
+// src/index.js
+
+import routes from './routes';
+
+const data = window.__INITIAL_DATA__;
+ReactDOM.hydrate(
+
+
+ ,
+ document.getElementById('root')
+);
+```
+
+```js
+// src/App.js
+
+import React from 'react';
+import { Route, Switch } from 'react-router';
+
+const App = ({ routes, initialData }) => {
+ return (
+
+ {routes.map((route, index) => {
+ return (
+
+ React.createElement(route.component, {
+ ...props,
+ routes: route.routes,
+ initialData: initialData[index] || null
+ })
+ }
+ />
+ );
+ })}
+
+ );
+};
+```
+
+```js
+// src/routes/DemoPage/AboutView.js
+
+import React from 'react';
+
+import withSSR from '../../components/withSSR';
+
+class AboutView extends React.Component {
+ static getInitialData({ match, req, res }) {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ resolve({
+ article: `
+This text is ALSO server rendered if and only if it's the initial render.
+ `,
+ currentRoute: match.pathname
+ });
+ }, 500);
+ });
+ }
+
+ render() {
+ const { isLoading, article, error } = this.props;
+ return (
+
+
About
+ {isLoading &&
Loading from client...
}
+ {error &&
{JSON.stringify(error, null, 2)}
}
+ {article &&
{article}
}
+
+ );
+ }
+}
+
+export default withSSR(AboutView);
+```
+
+Please get the `withSSR` code [here](https://github.com/jaredpalmer/react-router-nextjs-like-data-fetching/blob/master/src/components/withSSR.js)
+
+## License
+
+MIT
diff --git a/packages/@cra-express/router-prefetcher/jest-config.json b/packages/@cra-express/router-prefetcher/jest-config.json
new file mode 100644
index 00000000..d4a88bd8
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/jest-config.json
@@ -0,0 +1,7 @@
+{
+ "testEnvironment": "node",
+ "moduleFileExtensions": ["js"],
+ "collectCoverageFrom": ["src/**/*.{js}"],
+ "testRegex": "(src)/.*\\.spec\\.js$"
+ }
+
\ No newline at end of file
diff --git a/packages/@cra-express/router-prefetcher/package.json b/packages/@cra-express/router-prefetcher/package.json
new file mode 100644
index 00000000..cf3ac7ff
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@cra-express/router-prefetcher",
+ "version": "4.0.0-alpha.2",
+ "description": "Router Prefetcher for prefetching on server",
+ "main": "lib/index.js",
+ "files": [
+ "lib"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "babel src -d lib --ignore spec.js",
+ "watch": "babel src -d lib --watch --ignore spec.js",
+ "test": "jest --watch --config=jest-config.json",
+ "test:ci": "jest --config=jest-config.json --coverage"
+ },
+ "author": "Antony Budianto ",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-router-dom": "*"
+ },
+ "devDependencies": {
+ "react": "^16.5.2",
+ "react-router-dom": "^4.3.1"
+ }
+}
diff --git a/packages/@cra-express/router-prefetcher/src/index.js b/packages/@cra-express/router-prefetcher/src/index.js
new file mode 100644
index 00000000..40485188
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/src/index.js
@@ -0,0 +1,25 @@
+import { matchPath } from 'react-router-dom';
+
+export function getInitialData(req, res, routes) {
+ const matches = routes.map((route, index) => {
+ const match = matchPath(req.url, route.path, route);
+ if (match) {
+ const obj = {
+ route,
+ match,
+ promise: route.component.getInitialData
+ ? route.component.getInitialData({
+ match,
+ req,
+ res
+ })
+ : Promise.resolve(null)
+ };
+ return obj;
+ }
+ return null;
+ });
+
+ const promises = matches.map(match => (match ? match.promise : null));
+ return Promise.all(promises);
+}
diff --git a/packages/@cra-express/router-prefetcher/src/index.spec.js b/packages/@cra-express/router-prefetcher/src/index.spec.js
new file mode 100644
index 00000000..3a3a06a3
--- /dev/null
+++ b/packages/@cra-express/router-prefetcher/src/index.spec.js
@@ -0,0 +1,43 @@
+import { getInitialData } from './index';
+
+jest.mock('react-router-dom');
+
+const rrd = require('react-router-dom');
+
+describe('getInitialData', () => {
+ it('should handle not match path', done => {
+ rrd.matchPath.mockReturnValue(false);
+ getInitialData({}, {}, [{}]).then(res => {
+ expect(res[0]).toBeNull();
+ done();
+ });
+ });
+
+ it('should handle matched path', done => {
+ rrd.matchPath.mockReturnValue(true);
+ getInitialData({}, {}, [
+ {
+ component: {
+ getInitialData() {
+ return Promise.resolve(1);
+ }
+ }
+ }
+ ]).then(res => {
+ expect(res[0]).toBe(1);
+ done();
+ });
+ });
+
+ it('should handle matched path with no getInitialData', done => {
+ rrd.matchPath.mockReturnValue(true);
+ getInitialData({}, {}, [
+ {
+ component: {}
+ }
+ ]).then(res => {
+ expect(res[0]).toBeNull();
+ done();
+ });
+ });
+});
diff --git a/packages/demo/.prettierrc b/packages/demo/.prettierrc
new file mode 100644
index 00000000..1fb73bc2
--- /dev/null
+++ b/packages/demo/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 2,
+ "singleQuote": true
+}
diff --git a/packages/demo/package.json b/packages/demo/package.json
index 82b125f1..f6a0e3f4 100644
--- a/packages/demo/package.json
+++ b/packages/demo/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@cra-express/core": "^4.0.0-alpha.2",
"@cra-express/redux-prefetcher": "^4.0.0-alpha.2",
+ "@cra-express/router-prefetcher": "^4.0.0-alpha.2",
"basscss": "8.0.4",
"font-awesome": "4.7.0",
"loadable-components": "2.2.3",
diff --git a/packages/demo/server/app.js b/packages/demo/server/app.js
index 1c58f4a1..b93589f4 100644
--- a/packages/demo/server/app.js
+++ b/packages/demo/server/app.js
@@ -1,7 +1,7 @@
import { getLoadableState } from 'loadable-components/server';
import thunk from 'redux-thunk';
import { createReactAppExpress } from '@cra-express/core';
-import { getInitialData } from '@cra-express/redux-prefetcher';
+import { getInitialData } from '@cra-express/router-prefetcher';
import routes from '../src/routes';
const path = require('path');
const React = require('react');
@@ -15,6 +15,7 @@ const clientBuildPath = path.resolve(__dirname, '../client');
let tag = '';
let store;
let AppClass = App;
+let serverData;
const app = createReactAppExpress({
clientBuildPath,
universalRender: handleUniversalRender,
@@ -27,6 +28,10 @@ const app = createReactAppExpress({
/`
);
}
@@ -35,13 +40,13 @@ const app = createReactAppExpress({
function handleUniversalRender(req, res) {
const context = {};
store = createStore(reducer, applyMiddleware(thunk));
- const expressCtx = { req, res };
- return getInitialData(expressCtx, store, routes)
- .then(result => {
+ return getInitialData(req, res, routes)
+ .then(data => {
+ serverData = data;
const app = (
-
+
);
diff --git a/packages/demo/src/App.js b/packages/demo/src/App.js
index 57cd39b9..87dd560f 100644
--- a/packages/demo/src/App.js
+++ b/packages/demo/src/App.js
@@ -1,29 +1,33 @@
-import React, { Component } from 'react';
+import React from 'react';
import { Route, Switch } from 'react-router';
-import routes from './routes';
import './App.css';
import 'basscss/css/basscss.css';
import 'font-awesome/css/font-awesome.css';
-const RouteWithSubRoutes = route => (
- (
- // pass the sub-routes down to keep nesting
-
- )}
- />
-);
-
-class App extends Component {
- render() {
- return (
-
- {routes.map((route, i) => )}
-
- );
- }
-}
+const App = ({ routes, initialData }) => {
+ return (
+
+ {routes.map((route, index) => {
+ // pass in the initialData from the server or window.DATA for this
+ // specific route
+ return (
+
+ React.createElement(route.component, {
+ ...props,
+ routes: route.routes,
+ initialData: initialData[index] || null
+ })
+ }
+ />
+ );
+ })}
+
+ );
+};
export default App;
diff --git a/packages/demo/src/components/withSSR.js b/packages/demo/src/components/withSSR.js
new file mode 100644
index 00000000..8231e825
--- /dev/null
+++ b/packages/demo/src/components/withSSR.js
@@ -0,0 +1,87 @@
+import React from 'react';
+
+// This is a Higher Order Component that abstracts duplicated data fetching
+// on the server and client.
+export default function SSR(Page) {
+ class SSR extends React.Component {
+ static getInitialData(ctx) {
+ // Need to call the wrapped components getInitialData if it exists
+ return Page.getInitialData
+ ? Page.getInitialData(ctx)
+ : Promise.resolve(null);
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ data: props.initialData,
+ isLoading: false
+ };
+ this.ignoreLastFetch = false;
+
+ this.fetchData = this.fetchData.bind(this);
+ }
+
+ componentDidMount() {
+ if (!this.state.data) {
+ this.fetchData();
+ }
+ }
+
+ componentWillUnmount() {
+ this.ignoreLastFetch = true;
+ }
+
+ fetchData() {
+ // if this.state.data is null, that means that the we are on the client.
+ // To get the data we need, we just call getInitialData again on mount.
+ if (!this.ignoreLastFetch) {
+ this.setState({ isLoading: true });
+ this.constructor.getInitialData({ match: this.props.match }).then(
+ data => {
+ this.setState({ data, isLoading: false });
+ },
+ error => {
+ this.setState(state => ({
+ data: { error },
+ isLoading: false
+ }));
+ }
+ );
+ }
+ }
+
+ render() {
+ // Flatten out all the props.
+ const { initialData, ...rest } = this.props;
+
+ // if we wanted to create an app-wide error component,
+ // we could also do that here using . However, it is
+ // more flexible to leave this up to the Routes themselves.
+ //
+ // if (rest.error && rest.error.code) {
+ //
+ // {/* cool error screen based on status code */}
+ //
+ // }
+
+ return (
+
+ );
+ }
+ }
+
+ SSR.displayName = `SSR(${getDisplayName(Page)})`;
+ return SSR;
+}
+
+// This make debugging easier. Components will show as SSR(MyComponent) in
+// react-dev-tools.
+function getDisplayName(WrappedComponent) {
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component';
+}
diff --git a/packages/demo/src/index.js b/packages/demo/src/index.js
index 036ffcb7..1fc03e44 100644
--- a/packages/demo/src/index.js
+++ b/packages/demo/src/index.js
@@ -9,6 +9,7 @@ import { loadComponents } from 'loadable-components';
import './index.css';
import App from './App';
import reducer from './reducers';
+import routes from './routes';
// import registerServiceWorker from './registerServiceWorker';
// Grab the state from a global variable injected into the server-generated HTML
@@ -19,11 +20,13 @@ delete window.__PRELOADED_STATE__;
const store = createStore(reducer, preloadedState, applyMiddleware(thunk));
+const data = window.__INITIAL_DATA__;
+
loadComponents().then(() => {
ReactDOM.hydrate(
-
+
,
document.getElementById('root')
diff --git a/packages/demo/src/routes/DemoPage/AboutView.js b/packages/demo/src/routes/DemoPage/AboutView.js
new file mode 100644
index 00000000..6b7b0d5f
--- /dev/null
+++ b/packages/demo/src/routes/DemoPage/AboutView.js
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import withSSR from '../../components/withSSR';
+import withDemoLayout from './withDemoLayout';
+
+class AboutView extends React.Component {
+ static getInitialData({ match, req, res }) {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ resolve({
+ article: `
+This text is ALSO server rendered if and only if it's the initial render.
+ `,
+ currentRoute: match.pathname
+ });
+ }, 500);
+ });
+ }
+
+ render() {
+ const { isLoading, article, error } = this.props;
+ return (
+
+
About
+ {isLoading &&
Loading from client...
}
+ {error &&
{JSON.stringify(error, null, 2)}
}
+ {article &&
{article}
}
+
+ );
+ }
+}
+
+export default withSSR(withDemoLayout(AboutView));
diff --git a/packages/demo/src/routes/DemoPage/DemoView.js b/packages/demo/src/routes/DemoPage/DemoView.js
deleted file mode 100644
index b9dfbf9a..00000000
--- a/packages/demo/src/routes/DemoPage/DemoView.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import React, { Component } from 'react';
-import { Switch, Route, NavLink as Link } from 'react-router-dom';
-
-const RouteWithSubRoutes = route => (
- (
- // pass the sub-routes down to keep nesting
-
- )}
- />
-);
-
-class DemoView extends Component {
- render() {
- return (
-
-
-
Welcome to React
-
-
- Home
-
-
- Feature
-
-
-
-
-
- {this.props.routes.map((route, i) => (
-
- ))}
-
-
-
- );
- }
-}
-
-export default DemoView;
diff --git a/packages/demo/src/routes/DemoPage/index.js b/packages/demo/src/routes/DemoPage/index.js
deleted file mode 100644
index 1687aa4c..00000000
--- a/packages/demo/src/routes/DemoPage/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import DemoView from './DemoView';
-import { demoRoutes } from './routes';
-
-export const DemoRoute = {
- path: '/demo',
- component: DemoView,
- routes: demoRoutes
-};
diff --git a/packages/demo/src/routes/DemoPage/routes.js b/packages/demo/src/routes/DemoPage/routes.js
index d31eec92..17f41582 100644
--- a/packages/demo/src/routes/DemoPage/routes.js
+++ b/packages/demo/src/routes/DemoPage/routes.js
@@ -1,9 +1,10 @@
import React from 'react';
import loadable from 'loadable-components';
import HomeView from './HomeView';
+import withDemoLayout from './withDemoLayout';
-const LoadableFeatView = loadable(() => import('./FeatureView'), {
- modules: ['./FeatureView'],
+const AboutView = loadable(() => import('./AboutView'), {
+ modules: ['./AboutView'],
LoadingComponent: props => Loading...
});
@@ -11,10 +12,10 @@ export const demoRoutes = [
{
path: '/demo',
exact: true,
- component: HomeView
+ component: withDemoLayout(HomeView)
},
{
- path: '/demo/feature',
- component: LoadableFeatView
+ path: '/demo/about',
+ component: AboutView
}
];
diff --git a/packages/demo/src/routes/DemoPage/withDemoLayout.js b/packages/demo/src/routes/DemoPage/withDemoLayout.js
new file mode 100644
index 00000000..6f38e5d9
--- /dev/null
+++ b/packages/demo/src/routes/DemoPage/withDemoLayout.js
@@ -0,0 +1,49 @@
+import React, { Component } from 'react';
+import { NavLink as Link } from 'react-router-dom';
+
+function withDemoLayout(Page) {
+ class DemoLayout extends Component {
+ render() {
+ return (
+
+
+
Welcome to React
+
+
+ Landing
+
+
+ Home
+
+
+ About
+
+
+
+
+
+ );
+ }
+ }
+
+ DemoLayout.displayName = `DemoLayout(${getDisplayName(Page)})`;
+ DemoLayout.getInitialData = Page.getInitialData;
+ return DemoLayout;
+}
+
+function getDisplayName(WrappedComponent) {
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component';
+}
+
+export default withDemoLayout;
diff --git a/packages/demo/src/routes/index.js b/packages/demo/src/routes/index.js
index faf2fc48..a4e25fef 100644
--- a/packages/demo/src/routes/index.js
+++ b/packages/demo/src/routes/index.js
@@ -1,5 +1,5 @@
import LandingView from './LandingView/LandingView';
-import { DemoRoute } from './DemoPage';
+import { demoRoutes } from './DemoPage/routes';
const routes = [
{
@@ -7,7 +7,7 @@ const routes = [
exact: true,
component: LandingView
},
- DemoRoute
+ ...demoRoutes
];
export default routes;