Today has been an odd mixture of highs and lows at work. I'm implementing custom theming within a NextJS project, and I've got to say that for something so simple with vanilla HTML and CSS, this is one of those tasks which the Jamstack just breaks 💔[1]
Still, it's teaching me a lot about some of the tools I'm using – Styled Components in particular. Some of those learnings are so fundamentally weird and unexpected that I felt it was worth noting them down. I imagine I'll hit them again in the future, and when I do they'll seem just as bizarre at first glance, even if (once you dig deep into what's going on) they kinda make sense deep down.
Passing Class Names To Neighbours
Oh boy, this was a distinctly unusual bug to pin down. As part of the theme integration, we're showing a notification at the top of all pages when a user first loads the site, to highlight the theme their system is defaulting to, based on OS and browser settings. Like any such notification, a user can dismiss it. But we also don't want to show it if the user has previously expressed a hard preference on the theme that they are using. If they're a regular return visitor, they shouldn't have to dismiss a message every single time they land on the site, nor should they have to set their preferred theme in every new tab they open.
To work around this, we're taking the standard approach of storing theme preferences in Local Storage and querying them before the page loads. If a preference is found, the notification is not rendered. Because we're doing all of this in React-land, I was simply querying Local Storage and then conditionally loading the notification based on the outcome, something like this[2]:
const notify = localStorage.getItem('theme') return ( <> { !notify && <Notification /> } <h2>Page Heading</h2> {...} </> )
This worked great locally: a new user with no theme preferences got the notification, could dismiss it, and then not see it until they either reloaded their session or started a new one. If they did set explicit preferences, they'd never see it on that device again. It was simple, loaded in and out of the DOM nicely, and seemed to work well.
Then I tested it with server-side rendering (SSR)... and things got weird 😵
On the face of it, the notification still worked exactly as intended. It would appear for the right users (and hide for the right ones, too); could be dismissed; persisted state as expected. It worked. But the rest of the homepage was borked. Sections were overlapping each other, images were squashed together, single column components had become multicolumn. Large chunks of CSS were just missing. It made no sense and didn't obviously connect to the notification component, because that seemed to be fine.
After a while of poking around, though, I discovered that what was actually happening was that class names were being shuffled down the DOM. So if you had an element tree that should look like this:
<header class="main-headline">{...}</header> <section class="highlights box">{...}</section> <section class="banner">{...}</section> <footer>{...}</footer>
Then what you'd actually get was:
<header >{...}</header> <section class="main-headline">{...}</section> <section class="highlights box">{...}</section> <footer class="banner">{...}</footer>
Each element had moved its classes to the next one down. That's why it looked like whole chunks of CSS were missing, or why element blocks were suddenly grids when they should have been single columns, etc. The styles were being applied, but to the wrong sections of code 🤯
Now I knew what was going on, but I still couldn't work out why? Luckily, I stumbled onto a couple of discussion threads on GitHub that were tangentially related. None of them had the same issue, exactly, but they all had two things in common:
- Server-side rendering;
- A conditionally injected component.
Huh... I have both of those things 🤔
It turns out that React was having an issue with my notification because, on the server (where it lacked the context from local storage), it was always being shown. However on the client, the notification was conditional, and sometimes would be present in the DOM and sometimes wouldn't. When the server and client didn't match, React did some weird stuff and the class names were mixed around.
Now, I'm still not sure of the exact point where this bug was coming into play, but from how it looks, React/Styled Components were adding the class names after hydration and trying to rectify that with what they expected from the server-rendered version. Rather than matching on elements, it looks like they matched on location in the DOM tree, so because the top element (Component A) was removed on the client, it caused Component B's classes to be incorrectly added to Component C.
That's a particularly unintuitive bug (for me, at least), but a quick test where the notification was always rendered fixed all of the other CSS issues and placed the class names in the right locations at all times. Switching from conditional rendering to toggling between display: flex
and display: none
on the component worked the same way, so now it can be shown or not, but it's always technically in the DOM and React no longer throws a fit trying to work out where to apply which styles: phew 🙌
Styling Components Based on Parent Attributes
Perhaps this is just a "me" thing, but I always assumed that a Styled Component could only ever write styles within its own scope. For those who've never used the system, a Styled Component is just a regular old HTML element but with a unique class name applied. So when you write something like this:
const MyStyledComponent = styled.section` color: red; `
The output in the DOM looks like this:
<style> .dsfhfdd { color: red; } </style> ... <section class="dsfhfdd">{...}</section>
As a result, any additional styles within the Styled Component should (to my mind) only apply to the children of that element. Now, in reality, I knew this wasn't the case, but that hadn't really clicked. For example, I know that if you do this:
const MyStyledComponent = styled.section` color: red; h2 { color: purple; } `
That will colour all <h2>
elements on the page purple. If you want to properly scope that rule, you need to do this:
const MyStyledComponent = styled.section` color: red; & h2 { color: purple; } `
That makes it so only <h2>
elements within the parent <section>
will have that rule applied, because in Styled Components' syntax the &
symbol is a shortcut to the unique class name (in this case: dsfhfdd
). It scopes the second rule within the context of the component and stops styles from bleeding out.
It's always irked me that component-scoping isn't the default in Styled Components. It feels like it would solve a lot of problems within component-based architectures if a component's styles only ever belonged to it and its children. Seems logical, right? Well, it did, until today.
As I've mentioned, I'm working on theming. For the most part we're doing this using CSS Custom Properties, so we have a data attribute set on the <body>
element and use that to directly control the values of various design tokens. In turn, these are referenced anywhere we need a colour value e.g. text colour, borders, backgrounds, etc.
But some components need to be tweaked a bit more than that. A common one is that in some themes we want headlines to be colourful, whereas in others we want them to be greyscale. Now, we could probably have come up with a clever token system that let us do that, but we also wanted to keep the number of colour variables to a minimum, so instead we have primary and secondary brand colours, text colours, and "surface" colours (basically backgrounds, but also things like shadows or borders). Want a coloured header? Use a primary brand colour. Ah, but then want it to be black on a certain theme? Now you want a text colour. Oh 😬
My solution had been to pass a theme prop through to the Styled Component and then dynamically change some CSS attributes based on the theme's value:
const MyStyledComponent = styled.section<{theme: string}>` color: var(--colour-primary); ${({theme}) => theme === 'dark' && css` color: var(--colour-text); `} `
Simple, right? Except this didn't work once we began using SSR. I mean, it still worked, but not on the first page load. I'm still not sure why, but hydration just didn't care that the theme had been changed (for example, by checking local storage and finding a prior user preference), so for any theme apart from the default (which is what the server expected), dynamic CSS was just ignored. To be clear, the top-level custom properties would switch over, but no component-specific overrides would be applied.
Looking into this, it turns out that it's just an issue with SSR. If you want to use SSR and have themeable components, you cannot do so with JavaScript; you have to use pure CSS. Great, how do I make that work?
Well, that's where the lack of scoping on Styled Components works to our advantage. Because I can write a style for any part of the page from any component, I can hook directly into that data attribute on the <body>
element and replace my conditional statement with a CSS rule, like so:
const MyStyledComponent = styled.section<{theme: string}>` color: var(--colour-primary); body[data-theme='dark'] & color: var(--colour-text); } `
The key here – and the part I've never fully grasped – is that the lack of scope, combined with the &
symbol shortcut, means you can do something otherwise tricky-to-impossible in CSS and look up the DOM tree[3] letting you hook into the component's parent elements directly.
The best part about this: it's a better technique! It places almost all of my theme logic into CSS and HTML directly, rather than relying on React/JavaScript (which, as this day shows, can be a bit flaky and unintuitive). All I need to do is change the value of that data attribute via JavaScript when a user changes their settings (i.e. toggling dark mode or forced colour mode) or selects a different theme within the UI, and the actual visual changes are all done via the CSSOM. Not only is that simpler, but it should also be a touch more performant, as browser engines generally prefer to make CSS updates on the fly than making DOM updates (hence the need for libraries like React in the first place).
So there you have it, a day of deep frustrations that result in some surprisingly useful new knowledge. I'll definitely be keeping in mind the pitfalls of the first issue moving forward, and am currently in the process of rewriting all our components to use CSS only for controlling themes, which is great. Overall, I'll definitely consider this to be a win 😁