Library Upgrade Guide: <style> (most CSS-in-JS libs) #110
Replies: 3 comments 13 replies
-
nit: you have q: can If I understand correctly the server-rendered output with streams can only look roughly like this: <!DOCTYPE html>
<html>
<head></head>
<body>
<div id="root">
<style>
.test {
color: hotpink;
}</style
><span class="test">Hello CodeSandbox</span>
</div>
</body>
</html> where the point is - in the SSR output the However, if we just accept this SSR output without any modifications we end up with a hydration mismatch like:
At least this is what happens today, a small demo can be found here: https://codesandbox.io/s/interesting-forest-wrm9b It seems that this problem has not been addressed anyhow in the given implementation recommendations - unless I've missed it. At the moment, in Emotion, we do this hacky thing: It happens roughly during the initialization of the consuming module. I understand that this is not ideal and doesn't work correctly with selective hydration - I'm mentioning this for completeness and to mention this additional problem that we are facing. It seems that there is no good point in time in which we could manipulate the SSR html to move things out of React way. I guess we could try to experiment with emitting |
Beta Was this translation helpful? Give feedback.
-
Is
It indeed works like This changes the injection order for most of the current CSS-in-JS libraries because most of them inject in render and that happens in the top->down order. This means that, most likely, this won't be a smooth migration for a lot of applications and that we can't feature-detect the new hook to use it when it's available. This requires a major version of a CSS-in-JS library. This is problematic because the injection order might affect what rules are applied through descendant selectors etc. I'm personally not a fan of those and I'm against using them but people tend to use them and a lot of CSS-in-JS libraries allow those to be used. |
Beta Was this translation helpful? Give feedback.
-
If style tags end up interspersed at random-ish points within a stream, might you inadvertently remove rules in use if you unmount a component that is an (incidental) ancestor of one of the style tags? |
Beta Was this translation helpful? Give feedback.
-
Library Upgrade Guide:
<style>
This is an upgrade guide for CSS libraries that generate new rules on the fly and insert them with
<style>
tags into the document. This would be most CSS-in-JS libraries designed specifically for React today - like styled-components, styled-jsx, react-native-web etc.NOTE: Make sure you read the section "When to Insert
<style>
on The Client". If you currently inject style rules "during render", it could make your library VERY slow in concurrent rendering.This guide is not for CSS libraries that generate external CSS files ahead of time. See the
<link rel="stylesheet" />
guide for that use case. Inlining<style>
from pregenerated bulk stylesheets to avoid roundtrips during SSR also falls into that category.Injecting Into the SSR Stream
Today, you might do something like this to collect all the rules that were generated during the render into one or more
<style>
tags that gets inlined into the HTML.This doesn't work for streaming since you won't have all the style tags up front. Instead, you'll need to collect all rules generated up until a certain point and emit them before the corresponding HTML. Then after React has rendered some more, you need to generate new rules.
To insert into the stream at the right timing, you'll need to provide an intermediate stream.
For Node.js that means creating a wrapper Writable.
Note that it's important to use a custom Writable instead of a Transform stream to support forwarding of the flush() command, to avoid GZIP buffering.
For a Web Streams, there's no GZIP flushing support anyway. Browser support is a little flaky but the principle is the same. Find a way to inject before whatever React writes.
Ensure that you've already written the
<!doctype html><html><head>
part before writing any style tags. Otherwise the style tags could be written before the doctype which would break the page.Note: React can write fractional HTML chunks so it's not safe to always inject HTML anywhere in a write call. The above technique relies on the fact that React won't render anything in between writing. We assume that no more link tags will be collected between fractional writes. It is not safe to write things after React since there can be another write call coming after it.
Document Order Precedence
Your generated CSS might have conflicting rules and relies on document order to resolve them. In case case you might want to use a second script that reorders them in the document.
That way you can stream in CSS rules late while still preserving document order. Most rules will hopefully be in the first call. So they're all plain HTML. This is what happens if you delay writing the React content until onAllReady for SEO purposes. So as long as you don't emit the script tag for the first call, you don't have to worry about affecting SEO.
Some CSS-in-JS solutions guarantee that there are no such conflicts and so in those cases you don't need to bother with it. You can just emit the style tag into the stream and leave it behind.
Selective Hydration
A common technique is to scan the document - once - for
<style>
tags upon hydration. This avoids inserting them again when the component hydrates.It's not enough to do this only once with streaming selective hydration. Hydration can start early before the stream is complete - so it's possible for new
<style>
tag to be added by the server before the hydration is done.Therefore, you need to scan once per ID that you're looking for. If a component is hydrating, it should've been inserted by the server already so you'll find it. If a new component renders and it's not found, then you'll need to add it.
It's possible that you add it on the client and then later the server stream adds the same rule again. You could add something to the inline scripts from the server that looks for an existing rules.
Since the parent that rendered the style can be hydrated first - it's also interactive - and can delete the child before it's hydrated. The typical case would be navigating to a different tab using a tab bar while the first tab is still hydrating. In this case, you would not ever delete this style tag since nothing will unmount.
Because of these two edges, it's possible that you might end up with additional rules hanging around that don't get deleted. Performance wise, this is ok since it's just the initial rules and just in an edge case.
However, if your system allows conflicting rules and relies on one component unmounting before the next one is shown then this could lead to a semantic issue where the old rules hanging around conflict with the new component. We don't really have a solution for this problem. If you're upgrading an existing site, it's probably fine though. Because this only happens once you opt-in to new Suspense features. It also only happens if the page is slow to hydrate fully before the user interacts with it.
That said, we don't recommend building a system where you can rely on conflicting rules being resolved by unmounting a previous component. For example, global rules. It makes it harder to do animations between two states. It's also not good for perf to constantly unmount and remount rules. If you avoid global rules like this, then you avoid a lot of problems like this one.
When to Insert
<style>
on The ClientWhen generating
<style>
tags on the client it's important to be aware of a particular performance issue. When you add or remove any CSS rules, you more or less have to reapply all rules that already existed to all nodes that already existed. Not just the changed ones. There are optimizations in browsers but at the end of the day, they don't really avoid this problem. People often have a hard time believing this but it's true, because implementing CSS efficiently comes with a lot of tradeoffs and this isn't one that's optimized for.You probably won't hit this issue until you have a lot of rules and a lot of DOM nodes, but it scales poorly on even reasonable sized sites.
There are ways to avoid this. The most important is the timing. You want to make sure that you do it as the same time as other changes to the DOM such as when React mutates the DOM and before anything reads from the layout (e.g. clientWidth) and before yielding to the browser for paint.
The other thing you can do to avoid this causing problems is to batch as much of this work as possible.
The worst thing you can do is insert them during React renders. E.g. this is bad:
Sometimes this can hide between layers of abstractions. Especially if the system is automatic and triggered by a module system, such as
require('...')
.In concurrent rendering, React can yield to the browser between renders. If you insert a new rule in a component, then React yields, the browser then have to see if those rules would apply to the existing tree. So it recalculates the style rules. Then React renders the next component, and then that component discovers a new rule and it happens again.
This effectively causes a recalculation of all CSS rules against all DOM nodes every frame while React is rendering. This is VERY slow.
This can happen even if you wait to insert the rules in a requestAnimationFrame callback, because the browser can fire these between React yields.
Another issue with doing it in render is that if it yields conflicting rules, they'll start showing up before the content they belong to.
There's a feature in React called getSnapshotBefore update. This triggers before any other mutations are done. This can be used to adjust scroll positions or manage animations etc. It's common for these to read layout. At best this will trigger recomputation of layout twice unnecessarily. E.g. Write in render, Read in snapshot (force layout), Write in React, Read in Layout Effect (force layout 2).
A fix for this is to put the insertion of rules into a
useLayoutEffect
. That ensures that it's inserted before the content is visible but not too early.Unfortunately, reading layout and doing a second pass is one of the main use cases for
useLayoutEffect
.Imagine you have some components that inserts styles and some components that reads layout. This causes the layout to be computed multiple times in a single pass. Additionally, if one hook tries to read the layout before the CSS has been inserted, it would read the wrong layout.
Therefore we're introducing a new Hook for this use case.
useInsertionEffect
useInsertionEffect
is new in React 18. It works roughly likeuseLayoutEffect
except you don't have access to refs to DOM nodes at this time.The use case is specifically for inserting global DOM nodes like
<style>
or SVG<defs>
. (Maybe global Portals.) It's not really meant to be used by anything else other than these CSS libraries.Therefore the recommended solution is to use this Hook to insert style sheets (or ref count them if you need to remove them):
This effect doesn't fire on the server (just like
useLayoutEffect
). You may need to track the usage of a rule on the server. We recommend doing that with a separate map in render. If you need to, you can also feature test if you're on the server.Future
While this technique for generating CSS is popular today, we've found that it has a number of problems that we'd like to avoid. Therefore we don't have plans for adding any solutions upstream to handle this in React. For the time being, we expect this to have to be handled by third-party libraries such as in this guide.
Our preferred solution is to use
<link rel="stylesheet">
for statically extracted styles and plain inline styles for dynamic values. E.g.<div style={{...}}>
. You could however build a CSS-in-JS library that extracts static rules into external files using a compiler. That's what we use at Facebook.It's tempting to use dynamically generated style sheets since it contains only the rules that are active on the page right now. However, this comes at a cost. Usually at the expense of runtime cost. It also comes at a cost of inserting new rules frequently which invalidates a lot of the work that has already been done.
Shared stylesheets are efficient to parse and are very cacheable. By extracting them into shared styles you are also effectively batching a lot of that work up front. This lets you avoid doing that work later on as the user starts interacting with the page.
If you also use techniques like atomic CSS or other per-rule class names (e.g. tailwind), the CSS file sizes tends to plateau and not grow much in size since a lot of parts of a page tend to use similar rules for different components.
Truly dynamic rules can't be done this way but those can use inline styles (
<div style={{...}}>
). Inline styles doesn't suffer from invalidating the whole page's rules and only applies to the node they're applied to. Unfortunately, the performance of reparsing similar rules means that using only inline styles isn't optimal neither. However, when combined, these two techniques work well.A possibly strategy that might help minimize these quirks in the future would be to use Shadow DOM to scope the rule invalidation, however, that currently comes with a lot of other downsides so is not a feasible strategy atm IMO.
Beta Was this translation helpful? Give feedback.
All reactions