├── .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 |
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 |
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 |
--------------------------------------------------------------------------------