DEV Community

Daniel Pertu
Daniel Pertu

Posted on

With JavaScript off our dark site renders white, and there is no noscript tag on any of its 72 pages

Ask our homepage for its opening tag:

curl -s https://pub-trivia.app/ | grep -o '<html[^>]*>'
# <html data-dpl-id="dpl_..." lang="en">
Enter fullscreen mode Exit fullscreen mode

No class. The <body> that follows it carries two font variables and font-sans antialiased, and no class either. Nothing in the document we serve says this site is dark.

The dark palette is a class in the stylesheet:

:root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; }
.dark { --background: 0 0%  9%;  --foreground: 0 0% 98%;      }
Enter fullscreen mode Exit fullscreen mode

next-themes is configured with defaultTheme: "dark" and attribute: "class", and it works by injecting a small blocking script into the head. That script reads localStorage, picks a theme, and sets the class on the documentElement before first paint. With JavaScript enabled there is no flash: the class is there before anything is painted, which is exactly what the script is for.

With JavaScript off, the class never arrives. No class means :root, and :root holds a white background and near-black text. Our dark marketing site renders as a light one, in a palette nobody has looked at in months.

There is one more mismatch stacked on top. The theme colour is a static meta tag:

<meta name="theme-color" content="#171717"/>
Enter fullscreen mode Exit fullscreen mode

That is the dark background, hardcoded, served to everybody. So in the no-JavaScript case, a mobile browser paints its address bar in near black and the page underneath it white.

No noscript, anywhere

I pulled all 72 URLs from the sitemap and counted:

pages fetched              72
<noscript> elements         0
<form> elements             0
Enter fullscreen mode Exit fullscreen mode

Zero of either. The second number is not a bug, it is where the forms are: every form on this site is on /login, /signup, /forgot-password or the password reset page, and those four are deliberately not in the sitemap. The 72 pages we ask Google to rank are documents with links on them.

Which is the nice part of this story. A page of prose with links does not need JavaScript, and 70 of the 72 are static HTML on a CDN. The parts that break without it are smaller than I expected, and more specific.

The five links that are not in the markup

The header has a Resources menu. Here is everything inside the <header> on the homepage, in the HTML:

/           PubTrivia
/features   Features
/solutions  Solutions
/pricing    Pricing
/login      Sign in
/signup     Get started
Enter fullscreen mode Exit fullscreen mode

Six links, and one <button aria-haspopup="menu" aria-expanded="false"> whose five items, Guides, Questions, Compare, Tools and the FAQ, appear nowhere in the document. They are built by the dropdown component when it opens, which is to say by JavaScript. grep Guides against the header markup returns nothing.

So with JavaScript off the header offers six destinations instead of eleven. The site is saved by its own footer, which lists 20 links as plain anchors on every page, including all four clusters the menu was hiding. That is luck rather than design: nobody chose the footer as the no-JavaScript fallback, it just happens to be a list of links.

Sign-in works. Being told you failed does not.

This surprised me. The login form in the served HTML:

<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_00c6919073..." />
Enter fullscreen mode Exit fullscreen mode

That is Next.js encoding a server action for the no-JavaScript path. The form is a real POST to the current URL with the action id in a hidden field, so a browser with scripting disabled can submit it, and the server runs the same action and issues the same redirect. Sign-in is progressively enhanced, and not one line of this app asked for that.

The error path is not. A failed sign-in sets a message in the action's returned state, and the only thing that renders it is a toast fired from an effect. Effects need JavaScript. With scripting off, a wrong password gives you the form again with nothing on it: no banner, no inline message, no change at all.

That is worth a moment, because it is the general shape of the problem. The framework gave us progressive enhancement for the hard half, the mutation, and we gave it away on the easy half by choosing a rendering mechanism, the toast, that only exists on the client.

Three of our four tools are furniture

The four free tools under /tools are client components. Their controls are server rendered, so they are visibly there, and none of them is inside a <form>:

page                    buttons  inputs  selects  forms
team-name-generator           8       0        0      0
scoresheet-generator          2       4        0      0
qr-code-generator             2       2        0      0
round-planner                 8       9        6      0
Enter fullscreen mode Exit fullscreen mode

Those are whole-page counts, so a couple of the buttons are the header's own menu trigger. Either way: with JavaScript off, 15 inputs, 6 selects and the best part of 20 buttons have nothing to submit to. The QR code is drawn in the browser, the scoresheet PDF is generated in the browser, the planner's arithmetic runs in the browser.

Except one. The team name generator server renders its whole corpus:

curl -s https://pub-trivia.app/tools/team-name-generator \
  | grep -o '<li[^>]*>[^<]\{3,60\}</li>' | wc -l
# 111
Enter fullscreen mode Exit fullscreen mode

110 names and one instruction. The button that draws six of them at random does not work, but every name it could ever draw is already in the document, so the tool that needs JavaScript least is the only one of the four that survives without it.

What I would change, in order

  1. Put the class in the layout. <html lang="en" className="dark"> makes the served document state its own theme. next-themes then overrides it for the handful of people who picked something else, and the no-JavaScript case is correct instead of inverted. One line.
  2. Render the menu items. Five anchors in the markup, hidden with CSS and revealed by the component, costs nothing and means the header is a list of links before it is a widget.
  3. Render form errors in the HTML. The toast can stay. The message should also be on the page, next to the field, where it survives both a disabled script engine and a second identical failure.

A <noscript> block is the thing I am least interested in adding. Telling somebody their browser is wrong is not a fallback.

Check it yourself

In Chrome, open DevTools, press Command-Shift-P, run "Disable JavaScript", and reload pub-trivia.app. The page comes back white. Open the header's Resources menu: nothing happens. Scroll to the footer: every link is still there.

Then go to pub-trivia.app/login with scripting still off, submit an email that does not exist, and watch the page tell you precisely nothing.

Top comments (0)