Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for server side suspense and streaming #923

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/streaming-server-side-rendering/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"env": {
"browser": true
},
"rules": {
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": "off"
}
}
1 change: 1 addition & 0 deletions examples/streaming-server-side-rendering/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/dist
42 changes: 42 additions & 0 deletions examples/streaming-server-side-rendering/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Get the streaming SSR example running

Steps:

1. Download repository

```bash
git clone https://github.com/gregberge/loadable-components.git
```

2. Install [https://yarnpkg.com/lang/en/docs/install](yarn) if haven't already
3. Install libary dependencies and build library

```bash
yarn
yarn build
```

4. Move into example directory

```bash
cd ./loadable-components/examples/streaming-server-side-rendering
```

5. Install project dependencies

```bash
yarn
```

5. Run locally or build and serve

```bash
yarn dev

# Or

yarn build
yarn start
```

🍻
32 changes: 32 additions & 0 deletions examples/streaming-server-side-rendering/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
function isWebTarget(caller) {
return Boolean(caller && caller.target === 'web')
}

function isWebpack(caller) {
return Boolean(caller && caller.name === 'babel-loader')
}

module.exports = api => {
const web = api.caller(isWebTarget)
const webpack = api.caller(isWebpack)

return {
presets: [
'@babel/preset-react',
[
'@babel/preset-env',
{
useBuiltIns: web ? 'entry' : undefined,
corejs: web ? 'core-js@3' : false,
targets: !web ? { node: 'current' } : undefined,
modules: webpack ? false : 'commonjs',
},
],
],
plugins: ['@babel/plugin-syntax-dynamic-import', '@loadable/babel-plugin',
/* ["transform-define", {
"process.env.NODE_ENV": process.env.NODE_ENV,
}] */
],
}
}
6 changes: 6 additions & 0 deletions examples/streaming-server-side-rendering/nodemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ignore": ["client", "public"],
"execMap": {
"js": "babel-node"
}
}
39 changes: 39 additions & 0 deletions examples/streaming-server-side-rendering/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"private": true,
"scripts": {
"dev": "nodemon src/server/main.js",
"build": "rm -Rf ./public && NODE_ENV=production yarn build:webpack && yarn build:lib",
"build:webpack": "webpack",
"build:lib": "babel -d lib src",
"start": "NODE_ENV=production node lib/server/main.js",
"link:all": "yarn link @loadable/babel-plugin && yarn link @loadable/server && yarn link @loadable/component"
},
"devDependencies": {
"@babel/cli": "^7.4.4",
"@babel/core": "^7.6.2",
"@babel/node": "^7.0.0",
"@babel/preset-env": "^7.6.2",
"@babel/preset-react": "^7.0.0",
"@loadable/babel-plugin": "file:./../../packages/babel-plugin",
"@loadable/component": "file:./../../packages/component",
"@loadable/server": "file:./../../packages/server",
"@loadable/webpack-plugin": "file:./../../packages/webpack-plugin",
"babel-loader": "^8.0.6",
"babel-plugin-transform-define": "^2.1.0",
"css-loader": "^2.1.1",
"mini-css-extract-plugin": "^0.6.0",
"nodemon": "^1.19.0",
"webpack": "^4.31.0",
"webpack-cli": "^3.3.2",
"webpack-dev-middleware": "^3.6.2",
"webpack-node-externals": "^1.7.2"
},
"dependencies": {
"core-js": "^3.0.1",
"express": "^4.18.2",
"moment": "^2.24.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4"
}
}
47 changes: 47 additions & 0 deletions examples/streaming-server-side-rendering/src/client/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react'

// eslint-disable-next-line import/no-extraneous-dependencies
import { reactLazy } from '@loadable/component'
import Html from './Html'

const A = reactLazy(() => import('./letters/A'))
const B = reactLazy(() => import('./letters/B'))
const C = reactLazy(() => import(/* webpackPreload: true */ './letters/C'))
const D = reactLazy(() => import(/* webpackPrefetch: true */ './letters/D'))
const E = reactLazy(() => import('./letters/E?param'), { ssr: false })
const X = reactLazy(props => import(`./letters/${props.letter}`))
const Sub = reactLazy(props => import(`./letters/${props.letter}/file`))
const RootSub = reactLazy(props => import(`./${props.letter}/file`))

const App = () => {
return (
<Html title="Hello">
<React.Suspense fallback="Loading">
<A />
<br />
<B />
<br />
<X letter="A" />
<br />
<X letter="F" />
<br />
<E />
<br />
<Sub letter="Z" />
<br />
<RootSub letter="Y" />
</React.Suspense>
</Html>
)
}

function Error({ error }) {
return (
<div>
<h1>Application Error</h1>
<pre style={{ whiteSpace: 'pre-wrap' }}>{error.stack}</pre>
</div>
);
}

export default App
23 changes: 23 additions & 0 deletions examples/streaming-server-side-rendering/src/client/Html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react'

export default function Html({ children, title }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="favicon.ico" />
<title>{title}</title>
</head>
<body>
<noscript
dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`
}}
/>
{children}
</body>
</html>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'Y'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* A CSS */
body {
background: pink;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// We simulate that "moment" is called in "A" and "B"

const A = () => 'A'


export default A
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// We simulate that "moment" is called in "A" and "B"

const B = () => 'B'


export default B
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'C'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'D'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'E'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'F'
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react'

const G = ({ prefix }) => <span className="my-cool-class">{prefix} - G</span>

export default G
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'Z'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default } from './App'

export const hello = 'hello'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'core-js'
import React from 'react'
import { hydrateRoot } from "react-dom/client";
// eslint-disable-next-line import/no-extraneous-dependencies
import App from './App'

hydrateRoot(document, <App />);
4 changes: 4 additions & 0 deletions examples/streaming-server-side-rendering/src/client/main.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* Main CSS */
h1 {
color: cyan;
}
128 changes: 128 additions & 0 deletions examples/streaming-server-side-rendering/src/server/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import path from 'path'
import express, { json } from 'express'
import React from 'react'
import { renderToPipeableStream } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server'
import { Writable } from 'stream';
import fs from 'fs';

const app = express()

app.use(express.static(path.join(__dirname, '../../public')))

if (process.env.NODE_ENV !== 'production') {
/* eslint-disable global-require, import/no-extraneous-dependencies */
const { default: webpackConfig } = require('../../webpack.config.babel')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpack = require('webpack')
/* eslint-enable global-require, import/no-extraneous-dependencies */

const compiler = webpack(webpackConfig)

app.use(
webpackDevMiddleware(compiler, {
logLevel: 'silent',
publicPath: '/dist/web',
writeToDisk(filePath) {
return /dist\/node\//.test(filePath) || /loadable-stats/.test(filePath)
},
}),
)
}

const nodeStats = path.resolve(
__dirname,
'../../public/dist/node/loadable-stats.json',
)

const webStats = path.resolve(
__dirname,
'../../public/dist/web/loadable-stats.json',
)

app.get('*', (req, res) => {
let didError = false;
let shellReady = false;
let firstWrite = true;

let statsNode = JSON.parse(fs.readFileSync(nodeStats))
let statsWeb = JSON.parse(fs.readFileSync(webStats))


const nodeExtractor = new ChunkExtractor({ stats: statsNode })
const { default: App } = nodeExtractor.requireEntrypoint()

const webExtractor = new ChunkExtractor({ stats: statsWeb })

const writeable = new Writable({
write(chunk, encoding, callback) {
// This should pick up any new link tags that hasn't been previously
// written to this stream. Should not write before html if nothing suspended.
if (shellReady && !firstWrite) {
const scriptTags = webExtractor.flushScriptTags()
const linkTags = webExtractor.flushLinkTags()
if (scriptTags) {
res.write(scriptTags, encoding)
}
if (linkTags) {
res.write(linkTags, encoding)
}
// Finally write whatever React tried to write.
}
firstWrite = false
res.write(chunk, encoding, callback)
},
final(callback) {
res.end()
callback()
},
flush() {
if (typeof res.flush === 'function') {
res.flush();
}
},
destroy(err) {
res.destroy(err ?? undefined)
}
})

const stream = renderToPipeableStream(webExtractor.collectChunks(<App />),
{
onShellReady() {
// The content above all Suspense boundaries is ready.
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(writeable);
shellReady = true;

},
onShellError(error) {
// Something errored before we could complete the shell so we emit an alternative shell.
res.statusCode = 500;
res.send(
'<!doctype html><p>Loading...</p><script src="clientrender.js"></script>'
);
},
onAllReady() {
// If you don't want streaming, use this instead of onShellReady.
// This will fire after the entire page content is ready.
// You can use this for crawlers or static generation.
// If nothing suspends, make sure scripts are written
writeable.write('')
// res.statusCode = didError ? 500 : 200;
// res.setHeader('Content-type', 'text/html');
// stream.pipe(res);
},
onError(err) {
didError = true;
console.error(err);
},
}
);


});

// eslint-disable-next-line no-console
app.listen(9000, () => console.log('Server started http://localhost:9000'))
Loading