react-rails icon indicating copy to clipboard operation
react-rails copied to clipboard

Request: Support For JSS-in-CSS in Server-Rendered Components

Open Undistraction opened this issue 6 years ago • 7 comments

JSS-in-CSS libraries are becoming more and more popular (e.g. styled-components with 18,000 stars at the moment). These libraries work by dynamically generating styles from within components and attaching these styles to the page via a dynamically generated (and updated) <style> tag. This works great out of the box for non-server-rendered components, however for server-rendered components this poses a problem, because in addition to the rendered HTML, the rendered styles also need to be attached to the page. Currently react-rails offers no API for this, and assumes that a component will return only its own rendered HTML from react_component().

There is a workaround of sorts as outline by @bakunyo in #864 which involves returning the rendered styles inside a <style> tag alongside the component's HTML, then dynamically moving the <style> tag to the document <head> before the component is hydrated (which would otherwise result in the unexpected styles being discarded). For obvious reasons this is a very hacky fix and far from ideal.

It seems to me that some kind of additional API needs to exist to allow a component to render a <style> tag to the <head> of the document. Given that ReactRailsUJS.serverRender currently returns a string, could it also support returning an Array containing the component string as the first value and the styles as the second? If this second argument is present it could be rendered to the <head> via a helper.

Undistraction avatar Aug 13 '18 15:08 Undistraction

In addition to your suggestion in the final paragraph, we could be making use of a content_for or similar helper to put it into the header of the layout.

The issue there would be that the gem needs to install itself into the header of the relevant layout, and then it needs to know when it's outputting styles or not.

Are you willing to put forward some of your time to provide a proof of concept for this? I haven't used styled-components myself so I'm at a natural disadvantage in getting it working at all.

BookOfGreg avatar Aug 13 '18 16:08 BookOfGreg

I'm definitely up for taking a run at this but my Rails is a little rusty so I'll probably need some help.

Here is a bare-bones project with my current approach working.

This is the patched server_rendering.js:

import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { ServerStyleSheet } from 'styled-components'

const componentRequireContext = require.context('components', true)
const ReactRailsUJS = require('react_ujs')
ReactRailsUJS.useContext(componentRequireContext)

ReactRailsUJS.serverRender = function(renderFunction, componentName, props) {
  const ComponentConstructor = this.getConstructor(componentName)
  const stylesheet = new ServerStyleSheet()
  const wrappedElement = stylesheet.collectStyles(
    <ComponentConstructor {...props} />
  )
  const text = ReactDOMServer[renderFunction](wrappedElement)

  // prepend the style tags to the component HTML
  return `${stylesheet.getStyleTags()}${text}`
}

This is the (styled-components-specific) approach I'm currently using to move the <style> tag to the head. It must run before the component is hydrated, so I'm running it in application.js:

document.addEventListener('DOMContentLoaded', function(event) {
  const styleElement = document.querySelector(
    'body style[data-styled-components]'
  )
  document.head.appendChild(styleElement)
})

I guess we'll need to allow a user control over the selector to support other libraries.

Undistraction avatar Aug 15 '18 09:08 Undistraction

Hi there 👋

I implemented support for styled-components, emotion, and others in ReactJS.NET

Styled components example

During server render, this is where the custom render functions get invoked.

Some documentation is here. Technically any consumer that implements IRenderFunctions can pass that along and it will get used during render, but I didn't document that functionality.

Hope that helps for whoever wants to take a stab at implementing it here.

dustinsoftware avatar Dec 03 '18 19:12 dustinsoftware

I've created a new helper based on @bakunyo's workaround that makes the usage a little more DRY:

  def styled_react_component(component, props, options, content_block_name)
    body = react_component(component, props, options)
    style = body.slice!(/<style.+style>/m)
    content_for content_block_name do
      style.html_safe
    end
    body.html_safe
  end

Then you can just call this in the ERB like this:

<%= styled_react_component 'App', {}, { prerender: true }, :head %>

I would say that it would probably be better to extend react_component to include an option similar to prerender: true like style_block: :head which would invoke the content_for when present.

With this, I think the only missing piece is somehow ensuring that ExecJS can return an array of strings, or a Ruby object that can be parsed for a body and style portion, with similar respective support for ReactRailsUJS.serverRender to allow us to return the DOM body and styles in a separated manner. That way, we can skip the slice! which is the only thing that seems hacky to me.

My ExecJS experience is limited (as much as my time) but I can try and make a PR for this later this summer if the approach above looks good.

schuylr avatar Jun 21 '19 15:06 schuylr

Here's another experimental solution. It does not require creating custom helpers, or parsing strings. You just add an initializer into your rails app, which changes react-rails config. However it does use subclassing to provide modified classes to the configuration. Should work with both controller and view rendering. Feel free to use this as a basis for an official solution, or let me know if this is flawed somehow, would be super helpful. 👍

maxim avatar Jun 28 '19 06:06 maxim

I've got the solution with material-ui v3. It's the implementation their configuration

import React from "react";
import ReactDOMServer from "react-dom/server";
import {SheetsRegistry} from "jss";
import JssProvider from "react-jss/lib/JssProvider";
import {
  MuiThemeProvider,
  createMuiTheme,
  createGenerateClassName
} from "@material-ui/core/styles";

const componentRequireContext = require.context(`react/components`, true);
const ReactRailsUJS = require(`react_ujs`);
ReactRailsUJS.useContext(componentRequireContext);

ReactRailsUJS.serverRender = function (renderFunction, componentName, props) {
  const ComponentConstructor = this.getConstructor(componentName);
  const sheetsRegistry = new SheetsRegistry();
  const sheetsManager = new Map();
  const theme = createMuiTheme({
    typography: {
      useNextVariants: true
    }
  });
  const generateClassName = createGenerateClassName();
  const html = ReactDOMServer[renderFunction](
      <JssProvider
        registry={sheetsRegistry}
        generateClassName={generateClassName}
      >
        <MuiThemeProvider theme={theme} sheetsManager={sheetsManager}>
          <ComponentConstructor {...props} />
        </MuiThemeProvider>
      </JssProvider>
  );
  const css = sheetsRegistry.toString();
  return `<style id="jss-server-side">${css}</style>${html}`;
};

And a client code

import React from "react";
import PropTypes from "prop-types";
import JssProvider from "react-jss/lib/JssProvider";
import {
  MuiThemeProvider,
  createMuiTheme,
  createGenerateClassName
} from "@material-ui/core/styles";
import Root from "./components/Root";

class App extends React.Component {
  static propTypes = {};
  // Remove the server-side injected CSS.
  componentDidMount() {
    const jssStyles = document.getElementById(`jss-server-side`);
    if (jssStyles && jssStyles.parentNode) {
      jssStyles.parentNode.removeChild(jssStyles);
    }
  }

  render() {
    const theme = createMuiTheme({
      typography: {
        useNextVariants: true
      }
    });

    // Create a new class name generator.
    const generateClassName = createGenerateClassName();
    return (
      <JssProvider generateClassName={generateClassName}>
        <MuiThemeProvider theme={theme}>
          <Root />
        </MuiThemeProvider>
      </JssProvider>
    );
  }
}

export default App;

0ro avatar Aug 21 '19 12:08 0ro

Does anyone have thoughts on how to implement something similar using renderToNodeStream instead of renderToString as the renderFunction passed to the serverRender method? I'm working on doing this at the moment with a similar setup (react-rails / webpacker / styled-components), and am trying to see how renderToNodeStream could be incorporated for the react components / layouts.

kjalnes avatar Nov 23 '20 22:11 kjalnes

@kjalnes, were you able to resolve the issue? As per the discussion, we have a solution for this problem. Closing this for now. Feel free to reopen this.

alkesh26 avatar Nov 02 '22 14:11 alkesh26