FormidableLabs / rapscallion

Asynchronous React VirtualDOM renderer for SSR.
MIT License
1.39k stars 51 forks source link

Document how to handle CSS, CSS-in-JS SSR #39

Open jaredpalmer opened 7 years ago

jaredpalmer commented 7 years ago

I got rapscallion working with glamor/server. Will submit a pr soon.

aweary commented 7 years ago

@jaredpalmer can you share how you did that?

jaredpalmer commented 7 years ago
// server.js 
import 'source-map-support/register';
import express from 'express';
import compression from 'compression';
import path from 'path';
import React from 'react';
import { withAsyncComponents } from 'react-async-component';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { renderStatic } from 'glamor/server';
import template from './template';
import App from '../components/App';
import configureStore from '../store';
import { render } from 'rapscallion';

/* eslint-disable */
const clientAssets = require(KYT.ASSETS_MANIFEST);
/* eslint-enable */
const server = express();

// Remove annoying Express header addition.
server.disable('x-powered-by');

// Compress (gzip) assets in production.
server.use(compression());

// Setup the public directory so that we can server static assets.
server.use(express.static(path.join(process.cwd(), KYT.PUBLIC_DIR)));

// Setup server side routing.
server.get('*', (request, response) => {
  // First create a context for <StaticRouter>, which will allow us to
  // query for the results of the render.
  const reactRouterContext = {};

  const store = configureStore({
    sourceRequest: {
      protocol: request.headers['x-forwarded-proto'] || request.protocol,
      host: request.headers.host,
    },
  });

  const ReactRoot = (
    <StaticRouter location={request.url} context={reactRouterContext}>
      <Provider store={store}>
        <App />
      </Provider>
    </StaticRouter>
  );

  withAsyncComponents(ReactRoot)
    .then(({ appWithAsyncComponents, state, STATE_IDENTIFIER }) => {
      const html = template({
        root: renderStatic(() => render(appWithAsyncComponents)),
        initialState: store.getState(),
        jsBundle: clientAssets.main.js,
        cssBundle: clientAssets.main.css,
      });

      if (reactRouterContext.url) {
        response.writeHead(302, { Location: reactRouterContext.url });
        response.end();
        return;
      }
      html.toStream().pipe(response);
    })
    .catch(e => {
      console.log(e);
      response.status(500).json({ error: e.message, stack: e.stack });
    });
});

server.listen(parseInt(process.env.PORT || KYT.SERVER_PORT, 10), err => {
  if (err) {
    throw err;
  }
  console.log('> started');
});
// template.js
/* eslint-disable prefer-template, max-len */
import { template } from 'rapscallion';

export default vo => template`

<!DOCTYPE html>
<html lang="en">

  <head>
    <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
    <meta charSet='utf-8' />
    <title>Universal React</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#584095" />
    <meta name="mobile-web-app-capable" content="yes"/>
    <meta name="apple-mobile-web-app-capable" content="yes"/>
    <meta name="apple-mobile-web-app-title" content="NKBA"/>
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
    <link id="favicon" rel="shortcut icon" href="/kyt-favicon.png" sizes="16x16 32x32" type="image/png" />
    <link rel="apple-touch-icon" sizes="180x180" href="/icon/apple-touch-icon.png"/>
    <link rel="manifest" href="/manifest.json"/>
    <meta name="msapplication-tap-highlight" content="no"/>
    <meta name="msapplication-TileImage" content="/icon/ms-touch-icon-144x144-precomposed.png"/>
    <meta name="msapplication-TileColor" content="#F2F2F2"/>
    <meta name="theme-color" content="#673AB8"/>
    <style type="text/css">${() => vo.root.css}</style>
    <script type="text/javascript" async>
    !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on"];analytics.factory=function(t){return function(){var e=Array.prototype.slice.call(arguments);e.unshift(t);analytics.push(e);return analytics}};for(var t=0;t<analytics.methods.length;t++){var e=analytics.methods[t];analytics[e]=analytics.factory(e)}analytics.load=function(t){var e=document.createElement("script");e.type="text/javascript";e.async=!0;e.src=("https:"===document.location.protocol?"https://":"http://")+"cdn.segment.com/analytics.js/v1/"+t+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)};analytics.SNIPPET_VERSION="4.0.0";
      analytics.load("XXXXXXXXXXXXX");
      }}();
    </script>
  </head>
  <body>
    <div id="root"><div>${vo.root.html}</div></div>
    <script src="${() => vo.jsBundle}" defer></script>
    <script type="text/javascript">window._initialState = ${() =>
  JSON.stringify(vo.initialState) ||
    {}}; window._glam =${() => JSON.stringify(vo.root.ids)};</script>

  </body>

</html>

`;
// client.js
import React from 'react';
import { render } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { withAsyncComponents } from 'react-async-component';
import { Provider } from 'react-redux';
import configureStore from '../store';
import ReactHotLoader from './RHL';
import App from '../components/App';

import { rehydrate } from 'glamor';
rehydrate(window._glam);

const container = document.querySelector('#root');
const store = configureStore(window._initialState || {});

function renderApp(TheApp) {
  const app = (
    <ReactHotLoader>
      <Provider store={store}>
        <BrowserRouter>
          <TheApp />
        </BrowserRouter>
      </Provider>
    </ReactHotLoader>
  );

  // We use the react-async-component in order to support super easy code splitting
  // within our application.  It's important to use this helper
  // @see https://github.com/ctrlplusb/react-async-component
  withAsyncComponents(app).then(
    ({ appWithAsyncComponents }) => render(appWithAsyncComponents, container),
  );
}

// // The following is needed so that we can support hot reloading our application.
if (process.env.NODE_ENV === 'development' && module.hot) {
  // Accept changes to this file for hot reloading.
  module.hot.accept('./index.js');
  // Any changes to our App will cause a hotload re-render.
  module.hot.accept(
    '../components/App',
    () => renderApp(require('../components/App').default),
  );
}
// Execute the first render of our app.
renderApp(App);
jaredpalmer commented 7 years ago

@aweary still working through the checksum. currently has a double flicker, but the styles are there and 85/100 lighthouse on now.sh ain't too shabby.

jaredpalmer commented 7 years ago

@aweary if i add

document.querySelector("#root").setAttribute("data-react-checksum", "${vo.root.html.checksum()}")

to template.js(right after the initialState declaration), I get the following error:

Error: Renderer#checksum can only be invoked for a renderer converted to node stream.
    at Renderer.checksum (/Users/jared/workspace/XXXX/node_modules/rapscallion/src/renderer.js:31:13)
    at exports.default (/Users/jared/workspace/XXXX/build/server/webpack:/src/server/template.js:41:89)
    at /Users/jared/workspace/XXXX/build/server/webpack:/src/server/index.js:53:20
divmain commented 7 years ago

@jaredpalmer I think there may still be issues with the order of evaluation of the template segments. I'll reproduce this example this evening and make any edits that seem necessary. In particular, glamor's renderStatic looks a bit funky (it relies on a global singleton and assumes synchronous render) and will have to be worked around. But it's definitely doable.

divmain commented 7 years ago

I'm also thinking through how I want to document all of this. It might become cumbersome to document how-tos for individual React libraries. But these are definitely questions that people will be asking, so a FAQ that covers some of these common patterns might be really useful.

jaredpalmer commented 7 years ago

As cumbersome as it might be to do recipes for individual libs, it would drive adoption if things were copy and paste-ready though.

divmain commented 7 years ago

That's very true. The problem with full examples in the docs is that they're hard to keep up-to-date. Blog posts don't usually have that problem - people expect that they may be incorrect after awhile.

On the other hand, it might be worth including well-commented examples in the repo. They can be rendered in CI, and that way we'll have some guarantees regarding correctness. Do you think that would suffice @jaredpalmer?

jaredpalmer commented 7 years ago

I think there are really 4 big recipes:

You could probably combine the Redux and CSS-in-JS into one.

wmertens commented 7 years ago

Also, injecting stuff in <head/> with e.g. react-helmet, for example meta tags and document title.

bkniffler commented 7 years ago

I'd really love to see such demos, still wondering how to make this working with apollo and fela!

threepointone commented 7 years ago

I wrote a transforming stream that takes streamed html and inlines (glamor) css at the precise points its used.

Uncovered a bug in rapscallion in that it doesn't read glamor rules attached via data attribs. classnames work fine though. Will file separate issue for it.

If you're adventurous, using glamor with classnames, copy paste this file somewhere, and use as -

import inline from './path/to/file'
// ...
render(<App/>).pipe(inline()).pipe(res)  

as a bonus, this method has the benefit of not needing to inline css and ids at any point, and should "just work".

wmertens commented 7 years ago

So you inject relevant style tags whenever you encouter a new class?

Can you do that in all places in html, e.g. inside tables?

Does this not break the rendering checksum?

On Mon, Mar 13, 2017, 11:49 PM Sunil Pai notifications@github.com wrote:

I wrote a transforming stream that takes streamed html and inlines (glamor) css at the precise points its used.

Uncovered a bug in rapscallion in that it doesn't read glamor rules attached via data attribs. classnames work fine though. Will file separate issue for it.

If you're adventurous, using glamor with classnames, copy paste this file https://github.com/threepointone/rakt/blob/master/packages/rakt/src/inline-css.js somewhere, and use as -

import inline from './path/to/file'// ...render().pipe(inline()).pipe(res)

as a bonus, this method has the benefit of not needing to inline css and ids at any point, and should "just work".

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/FormidableLabs/rapscallion/issues/39#issuecomment-286268116, or mute the thread https://github.com/notifications/unsubscribe-auth/AADWlg7Mozhr1C1RP0bZuojF0FzP_uw5ks5rlcgDgaJpZM4MCFSO .

threepointone commented 7 years ago

Pretty much, yes. I have naive detection for classnames, and prepend a style tag before its first occurrence.

Unsure which tags it would break with, I'll try out tables and such.

It does break checksums. React 16 doesn't show the warnings tho 🤷‍♀️

threepointone commented 7 years ago

It appears the checksums break only when used with rapscallion. I might be integrating wrong. I'll try some variations and get back here.

ismay commented 7 years ago

For people who are wondering how to use rapscallion and styled-components (also a css-in-js lib) for SSR, this is how I'm doing it: https://github.com/ismay/ismaywolff.nl/blob/develop/src/server/handleRender.jsx#L18

Works like a charm.

wmertens commented 7 years ago

Just making sure: This first renders the entire app and then streams it, right? So streaming won't make a difference in time-to-first-byte?

Since the styles are in the , you need to render the entire app before you can send the head…

One solution for this would be to embed the styles just before the first component using them, but that's only really feasible with fragments.

On Wed, May 10, 2017 at 12:55 PM ismay notifications@github.com wrote:

For people who are wondering how to use rapscallion and styled-components (also a css-in-js lib) for SSR, this is how I'm doing it: https://github.com/ismay/ismaywolff.nl/blob/develop/src/server/handleRender.jsx#L18

Works like a charm.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/FormidableLabs/rapscallion/issues/39#issuecomment-300447174, or mute the thread https://github.com/notifications/unsubscribe-auth/AADWlg4KOiZKvNTRNi00R5lxvaa4Mdc6ks5r4ZeDgaJpZM4MCFSO .

ismay commented 7 years ago

Just making sure: This first renders the entire app and then streams it, right?

@wmertens Hmm, I hope not. I've based it on the example from the readme: https://github.com/FormidableLabs/rapscallion#example. So I hope I'm not doing anything stupid and losing all of rapscallions benefits..

The way I intended this to work is for it to start streaming immediately, and while it's doing that render <App />. Only thing it should be waiting for is for the promises from fetch to resolve and for each previous expression to be evaluated. Which is also how I understood the example from the readme to work, but I might be misunderstanding that.

But yeah, since the styles are only available after rendering <App /> you'll have to insert them in the <head /> clientside. Just like a couple of other things need to be handled clientside (like setting meta tags, checksum, etc.).

siddharthkp commented 7 years ago

@ismay I don't think you'll be able to get the styles as well while streaming. Related: https://github.com/styled-components/styled-components/issues/600#issuecomment-298732201

ismay commented 7 years ago

@siddharthkp, That's ok, as long as I can get them afterwards and then insert them in the head clientside.

siddharthkp commented 7 years ago

⚠️ Probably not the right issue for streaming + css-in-js discussion, let me know if I should create another issue.

@ismay Are you saying you will get the styles at the end of the streaming? If yes, the true benefit of stream is not realised.

ismay commented 7 years ago

Are you saying you will get the styles at the end of the streaming? If yes, the true benefit of stream is not realised.

@siddharthkp Currently yes. Anything else isn't possible with styled-components v2's api as far as I know. It'd be nice if the styles could be attached right away yeah, but there are still benefits to streaming nonetheless. I can start sending a response right away which enables the client to start downloading assets (etc.) earlier than if I'd render everything on the server and then start sending. The styles might be missing, but there's still other content besides that.

kitten commented 7 years ago

@ismay we could potentially place the style tags at the end of the body to bring streaming back properly, right?

siddharthkp commented 7 years ago

@philpl, I'm not sure about that. I imagine the real power is rendering above the fold content first and then follow it up by rest. that would include styles. styles coming at the end is not optimal

wmertens commented 7 years ago

(above-the-fold rendering is awesome for users, bad for search engines. Only do it when you detect a regular visitor)

Styles coming at the end means that while the html is loading, the browser renders everything unstyled.

I wonder if styled-components could hijack styled DOM elements that can stand it (everything except table and list tags) to inject a <style> tag as the first child?

On Wed, May 10, 2017 at 7:09 PM Siddharth Kshetrapal < notifications@github.com> wrote:

@philpl https://github.com/philpl, I'm not sure about that. I imagine the real power is rendering above the fold content first and then follow it up by rest. that would include styles. styles coming at the end is not optimal

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/FormidableLabs/rapscallion/issues/39#issuecomment-300549453, or mute the thread https://github.com/notifications/unsubscribe-auth/AADWln4GU-brbHpBTezh9h-tqHSoNuj0ks5r4e80gaJpZM4MCFSO .

ismay commented 7 years ago

we could potentially place the style tags at the end of the body to bring streaming back properly, right?

@philpl I'm not sure if that's valid. Browsers might parse it but I believe only the scoped attribute allows style tags in the body and scoped has been deprecated. Placing them in the head seems the most safe to me. Please correct me if I'm wrong.

And as @siddharthkp said, when streaming the most desirable implementation would be to stream html that includes styles as well so that there's no unstyled content. @wmertens' suggestion would be a way to achieve that, if it's possible.

(above-the-fold rendering is awesome for users, bad for search engines. Only do it when you detect a regular visitor)

@wmertens That got me thinking, hope I'm not derailing the issue (and let me know if I am), but I was thinking about how to handle this. Maybe set a cookie, and:

  1. If user is a returning visitor, just send an as simple as possible reply without fetching data and let the client render everything (since they have all resources already, and fetched data can be cached clientside), so it'll be fastest if the user renders it all locally.
  2. If user is new or has a stale cache, fetch data and stream to client with toStream(), to make sure content is visible as soon as possible.
  3. Maybe try to detect if user is a bot (https://github.com/biggora/express-useragent), and render completely with toPromise() before sending a reply, since there's not anyone waiting and accuracy is more important.

That would mean three different approaches for rendering. Maybe a good subject for a new issue where we can discuss adapting rendering to the request (and adding that to the readme).

jamesjjk commented 7 years ago

@jaredpalmer @wmertens Would be great to see how you can stream react markup and inject into the head.

I tried a similar approach to @threepointone - inlinePipe and was able to extract the head tag data generated by helmet however I would require to write some additional functionality to modify the top down order of the template renderer. Not sure if this is the correct approach, would be nice to use some type of callback or promise on the functions to sync the stream. Similar to your renderer. Specifically interested in putting data in the head (react-helmet) and retaining the benefit of a stream.