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;