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

[styles] Streaming server-side rendering #8503

Closed
pjuke opened this issue Oct 2, 2017 · 28 comments
Closed

[styles] Streaming server-side rendering #8503

pjuke opened this issue Oct 2, 2017 · 28 comments
Labels
new feature New feature or request package: styles Specific to @mui/styles. Legacy package, @material-ui/styled-engine is taking over in v5.
Milestone

Comments

@pjuke
Copy link

pjuke commented Oct 2, 2017

#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.

@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 2, 2017

@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

@oliviertassinari oliviertassinari added new feature New feature or request v1 labels Oct 2, 2017
@kof
Copy link
Contributor

kof commented Oct 2, 2017

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.

@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 2, 2017

@kof As far as I know, you have the properties when traversing a React tree.

@kof
Copy link
Contributor

kof commented Oct 2, 2017

In that case it should work already.

@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 2, 2017

@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.

@kof
Copy link
Contributor

kof commented Oct 2, 2017

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.

@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 2, 2017

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

@kof
Copy link
Contributor

kof commented Oct 2, 2017

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.

@oliviertassinari
Copy link
Member

@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

@kof
Copy link
Contributor

kof commented Oct 2, 2017

Is it a benchmark or just one pass?

@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 2, 2017

@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.

@kof
Copy link
Contributor

kof commented Oct 2, 2017

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.

@oliviertassinari oliviertassinari added discussion and removed new feature New feature or request labels Oct 6, 2017
@oliviertassinari oliviertassinari changed the title Calculate styles ahead of rendering? Streaming server-side rendering Oct 6, 2017
@oliviertassinari oliviertassinari added new feature New feature or request and removed discussion labels Oct 6, 2017
@oliviertassinari
Copy link
Member

oliviertassinari commented Oct 6, 2017

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())

@kof
Copy link
Contributor

kof commented Oct 6, 2017

source order specificity is a bitch

@kof
Copy link
Contributor

kof commented Oct 7, 2017

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).

@kof
Copy link
Contributor

kof commented Oct 7, 2017

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!

@kof
Copy link
Contributor

kof commented Oct 7, 2017

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.

@oliviertassinari
Copy link
Member

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.

@GuillaumeCisco
Copy link
Contributor

GuillaumeCisco commented Jul 27, 2018

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,

@matthoffner
Copy link

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);

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 29, 2018

@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 oliviertassinari added the priority: important This change can make a difference label Dec 29, 2018
@GuillaumeCisco
Copy link
Contributor

@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.

@casperan
Copy link

@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 ?

@oliviertassinari oliviertassinari added the package: styles Specific to @mui/styles. Legacy package, @material-ui/styled-engine is taking over in v5. label Mar 13, 2019
@kdelmonte
Copy link

kdelmonte commented Oct 9, 2019

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.

@RaulTsc
Copy link

RaulTsc commented Nov 5, 2019

@oliviertassinari is this on the roadmap?

@oliviertassinari
Copy link
Member

oliviertassinari commented Nov 5, 2019

@RaulTsc The native support for styled-components should bring this to the table :).

@oliviertassinari
Copy link
Member

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.

@oliviertassinari oliviertassinari changed the title Streaming server-side rendering [styles] Streaming server-side rendering Nov 11, 2020
@oliviertassinari oliviertassinari added this to the v5 milestone Nov 11, 2020
@oliviertassinari
Copy link
Member

An update, we have now made enough progress with the new @material-ui/styled-engine package in v5 to move toward a progressive removal of the @material-ui/styles package (based on JSS). The current plan:

  • In v5.0.beta.0, this package will come standalone, in complete isolation from the rest.
  • In v5.0.0, this package will be soft deprecated, not promoted in the docs, nor very actively supported.
  • During v5, work on making the migration easier to the new style API (sx prop + styled() API). We might for instance, invest in the documentation for using react-jss that has more or less the same API. We could also invest in an adapter to restore the previous API but with emotion, not JSS.
  • In v6.0.0, this package will be discontinued (withStyles/makeStyles API).

This was made possible by the awesome work of @mnajdova.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new feature New feature or request package: styles Specific to @mui/styles. Legacy package, @material-ui/styled-engine is taking over in v5.
Projects
None yet
Development

No branches or pull requests

8 participants