Material-ui: [styles] Streaming server-side rendering

Created on 2 Oct 2017  路  27Comments  路  Source: mui-org/material-ui

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 with renderToNodeStream().

In pseudo code:

let markup = (<MyApplication />);
let css = calculateStyles(markup);

let stream = renderToNodeStream(<MyApplication css={css} />);
// stream.pipe etc. ...

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.

enhancement styles

Most helpful comment

Following what styled-components does with their interleaveWithNodeStream its possible to get streaming rendering:

const transformer = new stream.Transform({
  transform: function appendStyleChunks(chunk, encoding, callback) {
    const sheet = sheetsRegistry.toString();
    const subsheet = sheet.slice(sheetOffset);
    sheetOffset = sheet.length;
    this.push(Buffer.from(`<style type="text/css">${subsheet}</style>${chunk.toString()}`));
    callback();
  }
});

renderToNodeStream(node).pipe(transformer);

All 27 comments

@pjuke Yeah, this is something we could be doing with a React tree traversal algorithm, no need to render everything.
apollo and loadable-components are good examples of such React tree traversal. cc @kof

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:

  1. Travers the React tree in order to send the CSS first.
  2. Get the class names and render the HTML with them.

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 traverseReact(). This function needs to take context into account. If we are lucky it has already been implemented by another library, e.g. react-apollo.

// 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:

  • graphql: 78ms
  • react: 104ms

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 <styles /> as we traverse it.

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 <style /> HTML elements.
What could go wrong?

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

  1. If you NEVER EVER use more than ONE class name on a node.
  2. If you NVER EVER use selectors which target elements outside of the component (parent and children).

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.
I don't think this issue is a priority. We might revisit it one year from now.

Has this issue achieved a point of maturity?
Just tried the new @material-ui for a new website and wanted to use ssr with streaming.
It did not work, for getting the css, I have to put the renderToStaticMarkup(app); line (I'm using emotion, which material-ui should use for a very long time).
I'm considering rewriting all my material-ui component with emotion style instead of jss, which is far too heavy and hard to work with... It's easier to write css with css syntax, than css with dirty caml-case, more reliable and maintainable.
Is there a way to correctly use streaming with material-ui?
Thank you,

Following what styled-components does with their interleaveWithNodeStream its possible to get streaming rendering:

const transformer = new stream.Transform({
  transform: function appendStyleChunks(chunk, encoding, callback) {
    const sheet = sheetsRegistry.toString();
    const subsheet = sheet.slice(sheetOffset);
    sheetOffset = sheet.length;
    this.push(Buffer.from(`<style type="text/css">${subsheet}</style>${chunk.toString()}`));
    callback();
  }
});

renderToNodeStream(node).pipe(transformer);

@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 @material-ui/styles package. On our benchmark, we are x2.6 faster than emotion and styled-components, only 47% slower than CSS modules (doing nothing).

@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.
But it deals with server load, not streaming. And in our case server loading won't be an issue (small team), but streaming is a really good enhancement as users won't have a good internet connection.
Regarding the benchmark, I rewrote it using the way emotion should be used. You can see the modifications here: https://github.com/mui-org/material-ui/pull/14065/files
As it appeared, emotion css version is the quickest far away from the others.

$> yarn styles
yarn run v1.12.3
$ cd ../../ && NODE_ENV=production BABEL_ENV=docs-production babel-node packages/material-ui-benchmark/src/styles.js --inspect=0.0.0.0:9229
Debugger listening on ws://0.0.0.0:9229/dd4030e6-5096-46fa-a9f3-637712cdb84b
For help, see: https://nodejs.org/en/docs/inspector
Box x 7,831 ops/sec 卤2.46% (175 runs sampled)
JSS naked x 64,658 ops/sec 卤2.05% (178 runs sampled)
WithStylesButton x 33,897 ops/sec 卤1.21% (183 runs sampled)
HookButton x 52,901 ops/sec 卤1.32% (183 runs sampled)
StyledComponentsButton x 9,530 ops/sec 卤1.73% (178 runs sampled)
EmotionButton x 19,088 ops/sec 卤3.05% (176 runs sampled)
EmotionCssButton x 100,423 ops/sec 卤1.20% (185 runs sampled)
EmotionServerCssButton x 76,640 ops/sec 卤1.19% (185 runs sampled)
Naked x 115,093 ops/sec 卤1.18% (181 runs sampled)
Fastest is Naked
Done in 114.25s.

I encourage you to review the modifications and test it for confirming these results which surprised me a lot!
Emotion css is 13% slower than naked.
While WithStylesButton is 71% slower and Emotion styled is 83,5 % slower.
StyledComponentsButton is the last one 92% slower.

Regarding the original issue, I succeeded making material-ui works correctly with server streaming thanks to @matthoffner comment. I simply call const materialUiCss = sheetsRegistry.toString(); in my stream.on('end', () => {...}) event and pass it to my lateChunk.

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).
Hope it will help others.

@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.
@callemall... if we should invite anyone from the community to help get this for material-ui, who would it be ?

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:

const { Transform } = require('stream');

const sheetReducer = (accumulator, currentSheet) => accumulator + currentSheet;
const combineSheets = (sheets) => sheets.reduce(sheetReducer, '');

const getMuiStyleStreamTransformer = (materialSheet) => {
  const {
    sheetsRegistry: { registry },
  } = materialSheet;

  let nextSheetBatchStart = 0;
  return new Transform({
    transform(chunk, encoding, callback) {
      if (!registry.length || nextSheetBatchStart === registry.length) {
        this.push(chunk);
        callback();
        return;
      }
      const sheets = registry.slice(nextSheetBatchStart);
      nextSheetBatchStart = registry.length;
      const combinedSheet = combineSheets(sheets);
      this.push(
        Buffer.from(`<style type="text/css" data-mui-style-streamed>${combinedSheet}</style>${chunk.toString()}`),
      );
      callback();
    },
  });
};

module.exports = getMuiStyleStreamTransformer;

An use it like this:

const materialSheet = new MaterialStyleSheet();

const jsx = materialSheet.collect((<App />))

const stream = ReactDOM.renderToNodeStream(jsx).pipe(getMuiStyleStreamTransformer(materialSheet));

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:

export const consolidateMuiStreamedStyles = () => {
  const streamedMuiStylesElements = document.querySelectorAll('style[data-mui-style-streamed]');
  if (!streamedMuiStylesElements.length) {
    return;
  }
  const targetStyleElement = document.createElement('style');
  targetStyleElement.setAttribute('data-mui-style-streamed-combined', '');
  targetStyleElement.textContent = Array.from(streamedMuiStylesElements).reduce(
    (acc, styleEl) => acc + styleEl.textContent,
    '',
  );
  document.head.appendChild(targetStyleElement);
  Array.from(streamedMuiStylesElements).forEach((styleEl) => styleEl.parentNode.removeChild(styleEl));
};

export const removeMuiStreamedStyles = () => {
  const styleElement = document.querySelector('style[data-mui-style-streamed-combined]');
  if (!styleElement) {
    return;
  }
  styleElement.parentNode.removeChild(styleElement);
};

Use may use removeMuiStreamedStyles after hydration since the styles will be appended to the DOM by mui.

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.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

mattmiddlesworth picture mattmiddlesworth  路  3Comments

anthony-dandrea picture anthony-dandrea  路  3Comments

zabojad picture zabojad  路  3Comments

sys13 picture sys13  路  3Comments

ryanflorence picture ryanflorence  路  3Comments