-
-
Notifications
You must be signed in to change notification settings - Fork 32.4k
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
[styles] Streaming server-side rendering #8503
Comments
@pjuke Yeah, this is something we could be doing with a React tree traversal algorithm, no need to render everything. |
If function values are used, props are needed. To get them a component needs to render. Once component is rendered - we have styles. Otherwise we would need to statically extract styles and disable function values. |
@kof As far as I know, you have the properties when traversing a React tree. |
In that case it should work already. |
@kof What do you mean? We can:
|
If by traversing you mean some sort of virtual rendering with reconciliation etc then we should already have an extractable css. Otherwise I need to see what that traversing does. |
Here is a closer to reality implementation of @pjuke that we could use. The only part missing is // Create a sheetsRegistry instance to collect the generated CSS
const sheetsRegistry = new SheetsRegistry();
// Create a sheetManager instance to collect the class names
const sheetManager = new Map();
// Let's traverse the React tree, without generating the HTML.
traverseReact(
<JssProvider registry={sheetsRegistry}>
<MuiThemeProvider sheetManager={sheetManager}>
<MyApplication />
</MuiThemeProvider>
</JssProvider>
);
// Now, we can get the CSS and stream it to the client.
const css = sheetsRegistry.toString()
// Let's stream the HTML with the already generated class names
let stream = renderToNodeStream(
<MuiThemeProvider sheetManager={sheetManager}>
<MyApplication />
</MuiThemeProvider>
);
// stream.pipe etc. ... |
I am wondering how traverseReact could be implemented. In order to know props you need to do most things renderToString() does anyways. So the question would be is there any perf benefit then. |
@kof This is true. It needs to be benchmarked. Wait a second. I can do it right away on the project I'm working on. We use the same double pass rendering:
|
Is it a benchmark or just one pass? |
@kof this is a one pass on a large react tree with react@15. The results should be only relevant at the order of magnitude. It seems that traversing the React tree is what cost the most, it's not the HTML generation. I have heard that traversing the tree with react@16 is going to be faster. I wish react was providing a Babel plugin like API to traverse the react tree! This way, we could be writing a custom plugin injecting small chunks of So, I'm not sure we can push this issue forward. |
Even the order of magnitude can be wrong, you need to do many passes, because we have to see how v8 optimizes the code. From above numbers it is not worth streaming. Also don't forget that renderToNodeStream has also a cost. |
I have found the same issue on styled-components side. Actually, we can change the approach. It shouldn't be too hard to implement. We can use a post streaming logic. Let's say every time we render a style sheet, we inject the content into the HTML. Then, we have another transformation function that decodes and groups the style sheets into some import ReactDOMServer from 'react-dom/server'
import { inlineStream } from 'react-jss'
let html = ReactDOMServer.renderToStream(
<JssProvider stream={true}>
<App />
</JssProvider>
)
html = html.pipe(inlineStream()) |
source order specificity is a bitch |
So the only way it can work without any specificity issues is
|
If we find a way to restrict user and avoid those things completely, we might easily have a streamable css. And actually it is not impossible! |
When I said "never use more than one class", I meant regular rules which contain multiple properties and might override each other. If we take atomic css, which is one rule one property, then we might as well have many classes. Atomic has a bunch of other issues though which need to be avoided/lived with then. |
Some more thoughts: https://twitter.com/necolas/status/958795463074897920. |
Has this issue achieved a point of maturity? |
Following what styled-components does with their
|
@GuillaumeCisco Streaming the rendering won't necessarily yield better performance. I highly encourage you to read this Airbnb's article. You might not want to reconsider streaming after reading it. We have been working on improving the raw SSR performance lately through caching. It will be live in v3.8.0 under the @matthoffner This can work, I think that we should experiment with it! Do you have some time for that? I can think of one limitation. How should we handle overrides? I mean, can it be combined with styled-components, emotion or CSS modules? If not, I still think that it would be great first step! |
@oliviertassinari Thank you for this article. I've read it 5 months ago when it was published. Excellent one.
I encourage you to review the modifications and test it for confirming these results which surprised me a lot! Regarding the original issue, I succeeded making material-ui works correctly with server streaming thanks to @matthoffner comment. I simply call For people intereseted, code looks like: /* global APP_NAME META_DESCRIPTION META_KEYWORDS */
import React from 'react';
import config from 'config';
import {parse} from 'url';
import {Transform, PassThrough} from 'stream';
import redis from 'redis';
import {Provider} from 'react-redux';
import {renderToNodeStream} from 'react-dom/server';
import {renderStylesToNodeStream} from 'emotion-server';
import {ReportChunks} from 'react-universal-component';
import {clearChunks} from 'react-universal-component/server';
import flushChunks from 'webpack-flush-chunks';
import {JssProvider, SheetsRegistry} from 'react-jss';
import {MuiThemeProvider, createGenerateClassName} from '@material-ui/core/styles';
import {promisify} from 'util';
import routesMap from '../app/routesMap';
import vendors from '../../webpack/ssr/vendors';
import App from '../app';
import configureStore from './configureStore';
import serviceWorker from './serviceWorker';
import theme from '../common/theme/index';
const cache = redis.createClient({
host: config.redis.host,
port: config.redis.port,
});
const exists = promisify(cache.exists).bind(cache);
const get = promisify(cache.get).bind(cache);
cache.on('connect', () => {
console.log('CACHE CONNECTED');
});
const paths = Object.keys(routesMap).map(o => routesMap[o].path);
const createCacheStream = (key) => {
const bufferedChunks = [];
return new Transform({
// transform() is called with each chunk of data
transform(data, enc, cb) {
// We store the chunk of data (which is a Buffer) in memory
bufferedChunks.push(data);
// Then pass the data unchanged onwards to the next stream
cb(null, data);
},
// flush() is called when everything is done
flush(cb) {
// We concatenate all the buffered chunks of HTML to get the full HTML
// then cache it at "key"
// TODO support caching with _sw-precache
// only cache paths
if (paths.includes(key) && !(key.endsWith('.js.map') || key.endsWith('.ico')) || key === 'service-worker.js') {
console.log('CACHING: ', key);
cache.set(key, Buffer.concat(bufferedChunks));
}
cb();
},
});
};
// Create a sheetsRegistry instance.
const sheetsRegistry = new SheetsRegistry();
// Create a sheetsManager instance.
const sheetsManager = new Map();
// Create a new class name generator.
const generateClassName = createGenerateClassName();
const createApp = (App, store, chunkNames) => (
<ReportChunks report={chunkName => chunkNames.push(chunkName)}>
<Provider store={store}>
<JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
<MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
<App/>
</MuiThemeProvider>
</JssProvider>
</Provider>
</ReportChunks>
);
const flushDll = clientStats => clientStats.assets.reduce((p, c) => [
...p,
...(c.name.endsWith('dll.js') ? [`<script type="text/javascript" src="/${c.name}" defer></script>`] : []),
], []).join('\n');
const earlyChunk = (styles, stateJson) => `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${APP_NAME}</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
<meta name="description" content="${META_DESCRIPTION}"/>
<meta name="keywords" content="${META_KEYWORDS}" />
<meta name="theme-color" content="#000">
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<link rel="icon" sizes="192x192" href="launcher-icon-high-res.png">
${styles}
</head>
<body>
<noscript>
<div>Please enable javascript in your browser for displaying this website.</div>
</noscript>
<script>window.REDUX_STATE = ${stateJson}</script>
${process.env.NODE_ENV === 'production' ? '<script src="/raven.min.js" type="text/javascript" defer></script>' : ''}
<div id="root">`,
lateChunk = (cssHash, materialUiCss, js, dll) => `</div>
<style id="jss-server-side" type="text/css">${materialUiCss}</style>
${process.env.NODE_ENV === 'development' ? '<div id="devTools"></div>' : ''}
${cssHash}
${dll}
${js}
${serviceWorker}
</body>
</html>
`;
const renderStreamed = async (ctx, path, clientStats, outputPath) => {
// Grab the CSS from our sheetsRegistry.
clearChunks();
const store = await configureStore(ctx);
if (!store) return; // no store means redirect was already served
const stateJson = JSON.stringify(store.getState());
const {css} = flushChunks(clientStats, {outputPath});
const chunkNames = [];
const app = createApp(App, store, chunkNames);
const stream = renderToNodeStream(app).pipe(renderStylesToNodeStream());
// flush the head with css & js resource tags first so the download starts immediately
const early = earlyChunk(css, stateJson);
// DO not use redis cache on dev
let mainStream;
if (process.env.NODE_ENV === 'development') {
mainStream = ctx.body;
} else {
mainStream = createCacheStream(path);
mainStream.pipe(ctx.body);
}
mainStream.write(early);
stream.pipe(mainStream, {end: false});
stream.on('end', () => {
const {js, cssHash} = flushChunks(clientStats,
{
chunkNames,
outputPath,
// use splitchunks in production
...(process.env.NODE_ENV === 'production' ? {before: ['bootstrap', ...Object.keys(vendors), 'modules']} : {}),
});
// dll only in development
let dll = '';
if (process.env.NODE_ENV === 'development') {
dll = flushDll(clientStats);
}
console.log('CHUNK NAMES', chunkNames);
const materialUiCss = sheetsRegistry.toString();
const late = lateChunk(cssHash, materialUiCss, js, dll);
mainStream.end(late);
});
};
export default ({clientStats, outputPath}) => async (ctx) => {
ctx.body = new PassThrough(); // this is a stream
ctx.status = 200;
ctx.type = 'text/html';
console.log('REQUESTED ORIGINAL PATH:', ctx.originalUrl);
const url = parse(ctx.originalUrl);
let path = ctx.originalUrl;
// check if path is in our whitelist, else give 404 route
if (!paths.includes(url.pathname)
&& !ctx.originalUrl.endsWith('.ico')
&& ctx.originalUrl !== 'service-worker.js'
&& !(process.env.NODE_ENV === 'development' && ctx.originalUrl.endsWith('.js.map'))) {
path = '/404';
}
console.log('REQUESTED PARSED PATH:', path);
// DO not use redis cache on dev
if (process.env.NODE_ENV === 'development') {
renderStreamed(ctx, path, clientStats, outputPath);
} else {
const reply = await exists(path);
if (reply === 1) {
const reply = await get(path);
if (reply) {
console.log('CACHE KEY EXISTS: ', path);
// handle status 404
if (path === '/404') {
ctx.status = 404;
}
ctx.body.end(reply);
}
} else {
console.log('CACHE KEY DOES NOT EXIST: ', path);
await renderStreamed(ctx, path, clientStats, outputPath);
}
}
}; This piece of code support SSR Streaming with redis caching on one node server (no nginx, no haproxy for load balancing). |
@GuillaumeCisco did you check out styled-component's ServerStyleSheet#interleaveWithNodeStream? Far fetching here, but could it be integrated into the mix until jss/react-jss/material-ui/styles supports it, so material-ui will work properly with React Suspense SSR Streaming when it lands? A good primer would be able to make it work first with react-imported-component like styled-components do. |
This seems to be working fine for me as a hack. I hope we get a first class solution in the future. Create a module similar to this:
An use it like this:
In the front end side, you will have to consolidate the styles and get them out of React's way before hydration. In order to this, use:
Use may use Warning: I did test this and it works but maybe there are some edge cases. I'll keep you posted as I continue. |
@oliviertassinari is this on the roadmap? |
@RaulTsc The native support for styled-components should bring this to the table :). |
An update, this issue is being resolved in v5 thanks to #22342. So far, we have migrated the Slider in the lab, where this can be tested. |
An update, we have now made enough progress with the new
This was made possible by the awesome work of @mnajdova. |
#I'm looking into new features of React 0.16; in particular the new
renderToNodeStream()
method.To be able to use that, one would need to provide all the necessary CSS before any other body markup to avoid the Flash Of Unstyled Content issue.
Is it possible with your JSS implementation to utilize this? I do not want to run the
renderToString()
method since it is slow. I'm looking for a solution where the markup could be parsed and the styles could be calculated pre-render, and then later render the final app withrenderToNodeStream()
.In pseudo code:
I realize this might not be a material-ui specific issue, but rather related to JSS. But I'm curious if you already have thought about this for your framework.
The text was updated successfully, but these errors were encountered: