├── .babelrc ├── .browserslistrc ├── .gitignore ├── .postcssrc ├── LICENSE ├── README.md ├── app └── src │ ├── components │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── ProgressiveComponent │ │ └── index.js │ └── TextSection │ │ ├── TextSection.module.css │ │ └── index.js │ ├── images │ └── logo.svg │ ├── index.css │ ├── index.html │ └── index.js ├── package.json ├── server ├── api │ └── oyster-text.js ├── constants │ └── index.js ├── index.js ├── renderer │ ├── helpers.js │ └── index.js └── util │ └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | build 5 | .vscode -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "modules": true, 3 | "plugins": { 4 | "autoprefixer": { 5 | "grid": true 6 | }, 7 | "postcss-modules": { 8 | "globalModulePaths": [ 9 | "App.css" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dinesh Pandiyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Progressive Rendering with React 2 | 3 | This repo is an example setup to progressively render a React app from the server. 4 | -------------------------------------------------------------------------------- /app/src/components/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 10vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 40vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | 35 | @keyframes flash-bg { 36 | 0% { 37 | background-color: yellow; 38 | } 39 | 40 | 100% { 41 | background-color: inherit; 42 | } 43 | } 44 | 45 | .flash { 46 | animation-name: flash-bg; 47 | animation-duration: 250ms; 48 | animation-iteration-count: 1; 49 | animation-timing-function: ease; 50 | } 51 | -------------------------------------------------------------------------------- /app/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import logo from "../images/logo.svg"; 3 | import { getProgressiveComponent } from "./ProgressiveComponent"; 4 | import TextSection from "./TextSection"; 5 | import "./App.css"; 6 | 7 | let ProgressiveComponentPCOne; 8 | let ProgressiveComponentPCTwo; 9 | 10 | function App({ store: defaultStore }) { 11 | const [text, setText] = React.useState("This is server rendered content"); 12 | const [store, setStore] = React.useState(defaultStore); 13 | 14 | React.useEffect(() => { 15 | setText("Client-side hydration complete"); 16 | }, []); 17 | 18 | React.useEffect(() => { 19 | // when chunked script updates window.GLOBAL_STORE 20 | setStore(store); 21 | }, [Object.keys(store)]); 22 | 23 | if (!ProgressiveComponentPCOne) { 24 | ProgressiveComponentPCOne = getProgressiveComponent({ 25 | RENDER_FROM: store.RENDER_FROM, 26 | serverRenderId: "PCOne" 27 | }); 28 | } 29 | 30 | if (!ProgressiveComponentPCTwo) { 31 | ProgressiveComponentPCTwo = getProgressiveComponent({ 32 | RENDER_FROM: store.RENDER_FROM, 33 | serverRenderId: "PCTwo" 34 | }); 35 | } 36 | 37 | return ( 38 |
39 |
40 | logo 41 |

This React app is progressively rendered from the server.

42 | {text} 43 |
44 | 47 |
48 |
49 | } 50 | > 51 | 55 | 60 | 61 | 62 | 63 | 66 |
67 | 68 | } 69 | > 70 | 74 | 79 | 80 |
81 | 82 | ); 83 | } 84 | 85 | export default App; 86 | -------------------------------------------------------------------------------- /app/src/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /app/src/components/ProgressiveComponent/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const RENDER_FROM_TYPES = ["SERVER_PLACEHOLDER", "SERVER", "CLIENT"]; 4 | 5 | const Component = props => { 6 | const { serverRenderId = "", Tag = "div", RENDER_FROM, children } = props; 7 | const childrenWithNewProps = React.Children.map(children, child => 8 | React.cloneElement(child, { ...props, RENDER_FROM }) 9 | ); 10 | 11 | return {childrenWithNewProps}; 12 | }; 13 | 14 | // need to pass id, React.lazy doesn't take id directly 15 | // so we wrap it into a util 16 | const getProgressiveClientComponent = serverRenderId => { 17 | return React.lazy( 18 | () => 19 | new Promise(resolve => { 20 | const hydrateFnName = "hydrate" + serverRenderId; 21 | // if server sent the chunk before bundle is loaded on the client 22 | // this method will be available in global namespace 23 | // so we know the content is already in DOM and we just resolve the lazy promise 24 | if (window[hydrateFnName]) { 25 | console.log(`${serverRenderId} hydrated by suspense`); 26 | resolve({ 27 | default: Component 28 | }); 29 | } else { 30 | // if this method is not in global namespace 31 | // then bundle loaded before server sent the chunk 32 | // so we let the server chunk invoke this method to hydrate the component 33 | window[hydrateFnName] = () => { 34 | resolve({ 35 | default: Component 36 | }); 37 | }; 38 | } 39 | }) 40 | ); 41 | }; 42 | 43 | const ProgressiveServerComponent = Component; 44 | 45 | const getProgressiveComponent = ({ RENDER_FROM, serverRenderId }) => { 46 | if (RENDER_FROM === "CLIENT") 47 | return getProgressiveClientComponent(serverRenderId); 48 | 49 | return ProgressiveServerComponent; 50 | }; 51 | export { getProgressiveComponent }; 52 | -------------------------------------------------------------------------------- /app/src/components/TextSection/TextSection.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin: 2rem; 3 | padding: 2.5rem; 4 | border: 2px solid grey; 5 | font-size: 1.5rem; 6 | background: rgba(255, 0, 0, 0.1); 7 | position: relative; 8 | } 9 | 10 | .wrapper:after { 11 | content: "Progressively Rendered from Server"; 12 | font-size: 0.65rem; 13 | color: darkred; 14 | background: rgba(255, 0, 0, 0.2); 15 | position: absolute; 16 | bottom: 0; 17 | right: 0; 18 | padding: 0.5rem; 19 | } 20 | 21 | .isHydrated { 22 | background: rgba(0, 255, 0, 0.1); 23 | } 24 | 25 | .isHydrated:after { 26 | content: "Progressive Hydration Complete"; 27 | color: darkgreen; 28 | background: rgba(0, 255, 0, 0.2); 29 | } 30 | -------------------------------------------------------------------------------- /app/src/components/TextSection/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | import styles from "./TextSection.module.css"; 4 | 5 | function TextSection(props) { 6 | const { text, store, storeKey } = props; 7 | const [isHydated, setHydrated] = React.useState(false); 8 | 9 | // 'cos text prop is not updated in suspense when the app re-renders with new store 10 | // probably a bug with concurrent mode or i'm doing closure wrong 11 | let textToRender = store && store[storeKey]; 12 | 13 | React.useEffect(() => { 14 | setHydrated(true); 15 | }, []); 16 | 17 | if (!textToRender) return null; 18 | 19 | return ( 20 |
26 | {textToRender} 27 |
28 | ); 29 | } 30 | 31 | export default TextSection; 32 | -------------------------------------------------------------------------------- /app/src/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Progressive Rendering with React 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./components/App"; 5 | 6 | // when devving in client-only mode without server 7 | // window.GLOBAL_STORE = { 8 | // sectionOneText: "The whole world is my oyester", 9 | // sectionTwoText: "And I will render it progressively from my server", 10 | // RENDER_FROM: "CLIENT" 11 | // }; 12 | 13 | window.GLOBAL_STORE = window.GLOBAL_STORE || {}; 14 | window.GLOBAL_STORE.RENDER_FROM = "CLIENT"; 15 | 16 | const rootElement = document.getElementById("root"); 17 | 18 | // this executes only on the client 19 | ReactDOM.createRoot(rootElement, { 20 | hydrate: true 21 | }).render(); 22 | 23 | // Hot Module Replacement - haven't figured out yet 24 | if (module.hot) { 25 | module.hot.accept(); 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "progressive-rendering-react", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build:prod:client": "NODE_ENV=production parcel build app/src/index.html --out-dir app/build --public-url /dist/", 6 | "build:prod:server": "NODE_ENV=production parcel build server/index.js --out-dir server/build --public-url /dist/ --target node --no-cache", 7 | "clean": "rimraf dist app/build server/build .cache", 8 | "client:only": "NODE_ENV=development parcel app/src/index.html --out-dir app/build --public-url /dist/", 9 | "build:client": "NODE_ENV=development parcel build app/src/index.html --out-dir app/build --no-minify --public-url /dist/", 10 | "build:server": "NODE_ENV=development parcel build server/index.js --out-dir server/build --no-minify --public-url /dist/ --target node", 11 | "dev:client": "NODE_ENV=development nodemon --watch app/src/ -e js,css --ignore app/build/ --exec \"npm run build:client\"", 12 | "dev:server": "nodemon --watch server/ --watch app/build/ --ignore server/build/ --exec \"npm run build:server && npm run start:server\"", 13 | "start:server": "NODE_ENV=development node server/build/index", 14 | "dev": "NODE_ENV=development ttab npm run dev:client && ttab npm run dev:server", 15 | "start": "npm run dev" 16 | }, 17 | "dependencies": { 18 | "browser-or-node": "^1.2.1", 19 | "classnames": "^2.2.6", 20 | "express": "^4.17.1", 21 | "react": "experimental", 22 | "react-dom": "experimental" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.8.0", 26 | "@babel/preset-env": "^7.8.0", 27 | "@babel/preset-react": "^7.8.0", 28 | "autoprefixer": "^9.7.3", 29 | "babel-preset-react-app": "^9.1.0", 30 | "nodemon": "^2.0.2", 31 | "parcel-bundler": "^1.12.4", 32 | "postcss-modules": "^1.5.0", 33 | "ttab": "^0.6.1" 34 | }, 35 | "nodemonConfig": { 36 | "verbose": true, 37 | "delay": "2000" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/api/oyster-text.js: -------------------------------------------------------------------------------- 1 | import { simulateDelayMS } from "../util"; 2 | 3 | const getSectionOneText = async () => { 4 | await simulateDelayMS(2000); 5 | return "The whole world is my oyester"; 6 | }; 7 | 8 | const getSectionTwoText = async () => { 9 | await simulateDelayMS(1500); 10 | return "And I will render it progressively from my server"; 11 | }; 12 | 13 | module.exports = { 14 | getSectionOneText, 15 | getSectionTwoText 16 | }; 17 | -------------------------------------------------------------------------------- /server/constants/index.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | APP_BUILD_DIR: path.resolve(__dirname + "/../../app/build") 5 | }; 6 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const serverRenderer = require("./renderer").default; 3 | const CONSTANTS = require("./constants"); 4 | 5 | const PORT = 8080; 6 | const app = express(); 7 | const router = express.Router(); 8 | 9 | router.use("^/$", serverRenderer); 10 | router.use( 11 | "/dist", 12 | express.static(CONSTANTS.APP_BUILD_DIR, { 13 | maxAge: "30d" 14 | }) 15 | ); 16 | 17 | app.use(router); 18 | 19 | app.listen(PORT, () => { 20 | console.log(`Server running at http://localhost:${PORT}`); 21 | }); 22 | -------------------------------------------------------------------------------- /server/renderer/helpers.js: -------------------------------------------------------------------------------- 1 | const ReactDOMServer = require("react-dom/server"); 2 | 3 | const renderProgressiveComponentToScript = (serverRenderId, Component) => { 4 | const compMarkup = ReactDOMServer.renderToStaticMarkup(Component); 5 | const hydrateFnName = "hydrate" + serverRenderId; 6 | const stitchingScript = ``; 14 | // if need to render when js turned off 15 | // stitchingScript = stitchingScript + ``; 16 | return stitchingScript; 17 | }; 18 | 19 | const createStoreScript = (storeName = "GLOBAL_STORE") => { 20 | return ``; 21 | }; 22 | 23 | const createStoreAssignerScript = (storeName = "GLOBAL_STORE", key, value) => { 24 | return ``; 27 | }; 28 | 29 | module.exports = { 30 | renderProgressiveComponentToScript, 31 | createStoreScript, 32 | createStoreAssignerScript 33 | }; 34 | -------------------------------------------------------------------------------- /server/renderer/index.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import React from "react"; 4 | import ReactDOMServer from "react-dom/server"; 5 | 6 | import CONSTANTS from "../constants"; 7 | import { getSectionOneText, getSectionTwoText } from "../api/oyster-text"; 8 | import { 9 | renderProgressiveComponentToScript, 10 | createStoreScript, 11 | createStoreAssignerScript 12 | } from "./helpers"; 13 | import App from "../../app/src/components/App"; 14 | import { getProgressiveComponent } from "../../app/src/components/ProgressiveComponent"; 15 | import TextSection from "../../app/src/components/TextSection"; 16 | 17 | // express server route callback 18 | const serverRenderer = async (req, res, next) => { 19 | const renderedAppHTMLStr = ReactDOMServer.renderToString( 20 | 21 | ); 22 | 23 | global.GLOBAL_STORE = {}; 24 | 25 | fs.readFile( 26 | path.join(CONSTANTS.APP_BUILD_DIR, "index.html"), 27 | "utf8", 28 | async (err, data) => { 29 | if (err) { 30 | console.error(err); 31 | return res.status(500).send("An error occurred!!"); 32 | } 33 | 34 | const firstChunk = data.replace( 35 | '
', 36 | `
${renderedAppHTMLStr}
` 37 | ); 38 | 39 | res.status(200); 40 | res.type("text/html; charset=utf-8"); 41 | 42 | res.write(firstChunk); 43 | 44 | res.write(createStoreScript("GLOBAL_STORE")); 45 | 46 | global.GLOBAL_STORE.sectionOneText = await getSectionOneText(); 47 | res.write( 48 | createStoreAssignerScript( 49 | "GLOBAL_STORE", 50 | "sectionOneText", 51 | global.GLOBAL_STORE.sectionOneText 52 | ) 53 | ); 54 | const ProgressiveComponentPCOne = getProgressiveComponent({ 55 | RENDER_FROM: "SERVER", 56 | serverRenderId: "PCOne" 57 | }); 58 | const PCOneScript = renderProgressiveComponentToScript( 59 | "PCOne", 60 | 64 | 69 | 70 | ); 71 | res.write(PCOneScript); 72 | 73 | global.GLOBAL_STORE.sectionTwoText = await getSectionTwoText(); 74 | res.write( 75 | createStoreAssignerScript( 76 | "GLOBAL_STORE", 77 | "sectionTwoText", 78 | global.GLOBAL_STORE.sectionTwoText 79 | ) 80 | ); 81 | 82 | const ProgressiveComponentPCTwo = getProgressiveComponent({ 83 | RENDER_FROM: "SERVER", 84 | serverRenderId: "PCTwo" 85 | }); 86 | const PCTwoScript = renderProgressiveComponentToScript( 87 | "PCTwo", 88 | 92 | 97 | 98 | ); 99 | res.write(PCTwoScript); 100 | res.end(); 101 | } 102 | ); 103 | }; 104 | 105 | export default serverRenderer; 106 | -------------------------------------------------------------------------------- /server/util/index.js: -------------------------------------------------------------------------------- 1 | const simulateDelayMS = async function(ms = 1000) { 2 | return await new Promise(resolve => { 3 | let waitT = setTimeout(() => { 4 | clearTimeout(waitT); 5 | resolve(); 6 | }, ms); 7 | }); 8 | }; 9 | 10 | module.exports = { 11 | simulateDelayMS 12 | }; 13 | --------------------------------------------------------------------------------