There is a <script> in our <head> with no defer and no async, and an eslint-disable above it:
{/* Inline script blocks first paint until theme class is applied - prevents flash */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
View source on cogniprep.app and you will find it, with a JSON array of paths inlined in the middle. Both of those things are deliberate, and the array is the more interesting one.
Why a blocking script is the right answer here
The theme class lives on <html>. Anything that applies it after paint produces the flash: a white page for one frame, then the dark one. useEffect runs after paint. A deferred script runs after parsing. Both are too late by definition, because "too late" here means one frame.
So the script is synchronous, it runs before the body exists, and it is small enough that the cost is measured in a fraction of a millisecond. This is the rare case where blocking the parser is the cheap option and every alternative is more expensive to the user.
Everything inside it is wrapped in try { } catch(e) {} with an empty handler. That is not laziness. localStorage throws in some privacy configurations, and a theme preference is not worth a blank page. Failing to the server-rendered default is the correct outcome.
The list, and why it is not typed into the script
Some routes ignore your preference entirely. The marketing and content pages are designed dark, so they are dark whatever your settings say. That is a list of paths, and a list of paths in a string of JavaScript inside a layout file is exactly where a bug goes to live unnoticed.
It did. The list existed twice: once inline in this script, and once in the client component that handles the same decision during client side navigation. Both copies had to agree, and they had drifted. Two provider pages rendered light while their nine siblings rendered dark, and nobody noticed for a while because you only see it if you visit those specific two pages.
The list now lives in lib/theme/force-dark-paths.ts and is serialised into the script at build time:
const themeScript = `(function(){
try {
var root = document.documentElement;
var path = window.location.pathname;
var forceDarkPaths = ${JSON.stringify(FORCE_DARK_PATHS)};
...
JSON.stringify of an imported constant. The inline script and the navigation component now read the same 45 entries by construction, and href lists cannot disagree with pathname checks any more.
Deduplicating did not fix it
This is the part I would want to read in someone else's post.
Merging the two copies removed the ability for them to disagree with each other. It did nothing about the actual recurring bug, which is that adding a new provider means adding a page and forgetting the entry. It drifted again for two more providers after the deduplication, for exactly that reason.
The thing that fixed it was three lines in a test:
// __tests__/theme/force-dark-paths.test.ts asserts that every provider in
// ALL_PROVIDERS has an entry here. Adding a provider without one is a test
// failure rather than a page someone has to notice is the wrong colour.
A single source of truth stops two lists contradicting each other. It does not stop one list being incomplete. Those are different failures and they need different tools: a shared module for the first, an assertion against another source for the second. Most of the "single source of truth" advice I read only addresses the first one.
Exact match, and how to see it
shouldForceDark uses includes, so matching is exact. /blogs is forced dark. /blogs/some-article is not, and follows your preference. You can check that in about twenty seconds on the live site:
localStorage.setItem('theme', 'light');
location.reload();
// then compare:
// https://cogniprep.app/blogs -> stays dark
// https://cogniprep.app/blogs/<any-post> -> renders light
document.documentElement.className; // does it contain "dark"?
document.documentElement.style.colorScheme;
Measured just now on the live deployment, with theme set to light:
/blogs class "... dark" colorScheme dark
/blogs/which-assessment-provider-am-i-taking class "..." colorScheme light
That is prefix semantics deliberately rejected. A hub page is a designed marketing surface; the articles under it are long reading, and someone who chose light mode for reading should get it. Exact matching makes the rule "this specific page", which is easy to reason about, at the cost of having to list every page you mean. With a generated list and a test guarding its completeness, that cost is fine.
Two small things the script also gets right, worth copying if you write one:
root.style.colorScheme is set alongside the class. Without it the browser paints its own scrollbars and form controls in the wrong scheme, which is a surprisingly visible half-applied dark mode.
suppressHydrationWarning is on both <html> and <body>. The script mutates the DOM before React hydrates, which is precisely the situation React is designed to complain about, and here the mutation is the point.
The honest limitation
'system' is the default, and the script reads matchMedia('(prefers-color-scheme: dark)') for it. That is right on first load, and it means the very first frame for a brand new visitor depends on an OS setting rather than anything we control. Nothing to fix, but worth knowing when someone reports that a page "looked wrong" and cannot reproduce it: the first question is what their system theme is.
Try the snippet above on any article. If your own app ships a theme toggle, the test to steal is not the script. It is the one that fails your build when a new route is added without a decision about its colour.
Top comments (0)