react-rails
react-rails copied to clipboard
Request: Support For JSS-in-CSS in Server-Rendered Components
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.
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.
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.
Hi there 👋
I implemented support for styled-components, emotion, and others in ReactJS.NET
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.
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.
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. 👍
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;
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, 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.