Material UI v5 - Emotion SSR

Hi,

Has anyone gave a shot at updating Meteor to Material UI v5, with Emotion + SSR?
I’ve set it up, but I cannot succeed to fill Emotion cache. It behaves as if none of the style it found was “critical” enough to be SSRed. So the styled tag ends up being empty.

Yet, everything seems to run smooth, it’s just that Emotion doesn’t detect the styles correctly. I’ve opened a ticket to get more info, because Emotion documentation is quite terse: SSR: more info about critical path extraction · Issue #2479 · emotion-js/emotion · GitHub

I’d be glad to get some feedback if some of you successfully setup Emotion SSR in a Meteor app.

1 Like

Yes, I have a version up just missing the user agent detection for responsive css injection. I’ll share the setup later. Would love feedback to improve the setup.

3 Likes

Hi @flean, would you please describe the length of “later”. Thank you.

1 Like

Lol, I’ll post it Monday

2 Likes

Hi @flean, which Monday?

Sorry @paulishca , I have been swamped and kids had a long weekend so I just started working today.

This should get the conversation started. I’m not sure I’m executing the mediaQuery part correctly.

On the App side I have issues using hydrate so I use render. Not sure how to use hydrate instead, any feedback is appreciated.

Server

import React from "react"
import { renderToString } from "react-dom/server"
import App from "../../ui/both/App"
import { LoadableCaptureProvider, preloadAllLoadables } from 'meteor/npdev:react-loadable'
import { FastRender } from "meteor/communitypackages:fast-render"
import { CacheProvider } from '@emotion/react';
import createEmotionServer from '@emotion/server/create-instance';
import creatEmotionCache from '../../ui/both/createEmotionCache'
import parser from 'ua-parser-js';
import mediaQuery from 'css-mediaquery';

preloadAllLoadables().then(() => {
	FastRender.onPageLoad( (sink) => {
		const deviceType = parser(sink.request.headers['user-agent']).device.type || 'desktop';
		console.log(deviceType)
		const ssrMatchMedia = (query) =>{ ({
			matches: mediaQuery.match(query, {
				// The estimated CSS width of the browser.
				width: deviceType === 'mobile' ? '0px' : '1024px',
			}),
		})};
		const cache = creatEmotionCache();
		const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);
		const loadableHandle = {};
		const helmetContext = {};
		const html = renderToString(
			<LoadableCaptureProvider handle={loadableHandle}>
				<CacheProvider value={cache}>
					<App location={sink.request.url} context={helmetContext} ssrMatchMedia={ssrMatchMedia} />
				</CacheProvider>
			</LoadableCaptureProvider>
		);
		// Grab the CSS from emotion
		const emotionChunks = extractCriticalToChunks(html);
		const emotionCss = constructStyleTagsFromChunks(emotionChunks);
		sink.appendToHead(emotionCss)
		sink.appendToHead(loadableHandle.toScriptTag());
		sink.renderIntoElementById("react-target", html);
	});
});

CLIENT MAIN

import "../imports/startup/client/index"
import React from 'react'
import { render } from "react-dom"
import App from '../imports/ui/both/App'
import { preloadLoadables } from 'meteor/npdev:react-loadable'
import { FastRender } from "meteor/communitypackages:fast-render"
import { CacheProvider } from '@emotion/react';
import createEmotionCache from '../imports/ui/both/createEmotionCache'

const cache = createEmotionCache()

function Main() {
	return (
		<CacheProvider value={cache}>
			<App/>
		</CacheProvider>
	);
}

FastRender.onPageLoad( () => {
	indexedDB.open("dummy")
	preloadLoadables().then(() =>{
		render(<Main/>, document.getElementById("react-target")
		)
	})
})

APP

import React, { useState , useEffect} from "react"
import Layout from "../layouts/Layout"
import Router from "./Router"
import NotFound from "../pages/NotFound"
import { Route , Switch } from "react-router-dom"
import { Helmet, HelmetProvider } from "react-helmet-async"
import useOneSignal from "../../api/oneSignal/hooks/useOneSignal"
import useUser from "../hooks/useUser"
import useRoutes from "../hooks/useRoutes"
import { ThemeProvider } from '@mui/material'
import { createTheme } from '@mui/material/styles';
import theme from '../both/theme'
import { modeState } from '../../api/states/ThemeState';


export default App = ({ location, context = {} , ssrMatchMedia }) => {
	const oneSignalResponse = useOneSignal({debug:false})

	const user = useUser()

	const yourRoutes = useRoutes({ user,isNav:false })
	
	const [activeTheme, setActiveTheme] = useState(createTheme(theme))

	// theme.props.MuiUseMediaQuery = ssrMatchMedia

	useEffect(() => {
		theme.palette.mode = modeState.get()
		setActiveTheme(createTheme(theme))
	}, [modeState.use()])

	return (
		<HelmetProvider context={context}>
			<Helmet>
				<title lang="en">APP</title>
			</Helmet>
			<Router location={location}>
				<ThemeProvider theme={activeTheme}>
					<Layout>
						<Switch>
							{yourRoutes.map(route =>
								<Route key={route.path} exact={route.exact} path={route.path} component={route.component}/>
							)}
							<Route component={NotFound}/>
						</Switch>
					</Layout>
				</ThemeProvider>
			</Router>
		</HelmetProvider>
	);
}

theme

import React from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { modeState } from '../../api/states/ThemeState';
// https://bareynol.github.io/mui-theme-creator/

const LinkBehavior = React.forwardRef((props, ref) => {
	const { href, ...other } = props;
	// Map href (MUI) -> to (react-router)
	return <RouterLink ref={ref} to={href} {...other} />;
});

// Create a theme instance.
const theme = {
	palette: {
		mode: modeState.get(),
		primary: {
			main: '#000000',
		},
		secondary: {
			main: '#0ebe96',
		},
	},
	components: {
		MuiLink: {
			defaultProps: {
				component: LinkBehavior,
			},
		}
	},
}

if (Meteor.isClient){
	console.log(theme)
}

export default theme;

createEmotionCache.js

import createCache from '@emotion/cache';

export default function createEmotionCache() {
  return createCache({ key: 'css' });
}
1 Like

Great stuff @flean , thanks for sharing!
Any insights on the roles of

import { preloadLoadables } from 'meteor/npdev:react-loadable'
import { FastRender } from "meteor/communitypackages:fast-render"

?
I guess that FastRender is the way you do SSR. This is maybe what I miss in my app. Instead, I use the onPageLoad provided by meteor/server-render, could that make a difference?

Also, preloading all “loadables” doesn’t have bad performance consequences? Since loadables are supposed to work client-side?

@eloyid that’s the thread about our issues with SSR you may want to follow it as well

Could you provide a bit of context about createEmotionCache? I don’t see it defined yet in the sample code you provided.

just added it to the post

1 Like

Is your site that uses this code public by any chance? I’d like to see it in action!

This particular version with material ui v5 isn’t live yet.

I’d love to see it when it is ready if convenient. You posted so much excellent source code that I’m thinking about trying SSR out. :slight_smile:

I’ll let you know, glad the code helped. I don’t think it’s perfect yet but it works. :joy:

1 Like

FastRender - polyfills Meteor.user() and other things during SSR, and captures data access (DDP - pub/sub), serializes it, and hydrates it in the client. It doesn’t do SSR completely, it just does the data portion.

react-loadable - Enables code splitting along component boundaries (set up as Loadables, usually at the route level). Also, captures all the used components during SSR. This includes all the components the SSR route needs, even on the other side of code split boundaries. (It’ll figure out if another set of Loadables have loaded after a code split).

You need both for SSR to work properly.

Does it effect performance? Of course! All optimizations are tradeoffs. SSR uses some server resources to deliver HTML to the client. That rendering takes server time, but it let’s the the client browser draw content before the data and components have loaded. That can appear to the client to have loaded faster.

Then fast-render hydrates the data - this is simply faster than waiting for the app to load, then fetching the data. Hence “fast render”.

Then react-loadable fetches and loads all the components needed to boot the app - ALL the components needed for that route, instead of loading each code-split chunk in serial. It does load all the components needed for the current route at once, so maybe a little faster (otherwise it has to load code-split chunks in serial) - which you would have done anyway, AND the HTML is visible during all this - so it APPEARS to load faster/more smoothly, than if you just loaded the app, then load all the data/components.

But it may or may not technically take more time to reach interactivity state, and because you are doing SSR, you are creating some back pressure on your app server. Tradeoffs!

But you can use clever devops tricks to side step that backpressure - all SSR chunks can be cached and distributed via CDN for example, and the rendering output can also be cached, if you don’t have auth, or user specific content to render (even then, you can use vary-by techniques to still get some of that benefit). You can also do a fancy division of SSR from your methods/pub-sub servers. There’s a lot you can do at that level.

2 Likes

Thanks a lot for the explanation for Fast Render, I use Apollo GraphQL for the data, so that explains why I don’t directly need it. However I may need to add react-loadable to my setup.

Does anybody have a site running using Meteor with MUI SSR that I could have a look at by any chance? I understand that most sites may be private and not available for public view of course.

I have one! But we had to disable SSR, because of a server side memory leak…

Did you get a big bump in your home page load speed when using SSR?

Most of the metrics google’s lighthouse are looking for improved when enabling SSR (correctly). But the application gets good marks even without all that. image