ReviewOS

stacks/stx

useColorMode has no pre-paint story, so it cannot replace the inline theme-boot script

#1794
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Verified against @stacksjs/stx@0.2.119, dist/signals.js. Related to #1788 (same composable, different defect) and #1793.

This is less a bug than a missing capability that two in-flight proposals already assume exists.

The claim

useColorMode (signals.js:3762) is repeatedly put forward as the replacement for the hand-written theme-bootstrap script that apps inline in <head>:

<script>
  ;(() => {
    const s = localStorage.getItem('theme') || 'system'
    const d = s === 'dark' || (s === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
    document.documentElement.classList.toggle('dark', d)
    document.documentElement.setAttribute('data-theme', d ? 'dark' : 'light')
  })()
</script>

It cannot replace it.

Why

That script exists for exactly one reason: it must run before first paint. It is deliberately render-blocking, placed above any stylesheet, and it touches the root element synchronously.

useColorMode is part of the client signals runtime. It reads localStorage at call time — after the runtime bundle has loaded and hydration has begun. Substituting it does not remove duplication so much as trade it for a guaranteed flash of the wrong theme on every cold load, for every user whose stored preference differs from the default. That is a strictly worse outcome than the duplication it set out to fix.

There is currently no hook that runs earlier: no SSR-time color-mode resolution, no documented way to emit a pre-paint snippet from config, no @head-level primitive that lands above the stylesheet.

Why this needs a decision rather than just a doc note

Two proposals in the standards RFC drafts are written on the assumption that the substitution works:

  • The strict-mode proposal justifies a no-bare-localstorage rule by arguing the bare localStorage hits are all theme-boot duplication and that useColorMode "replaces it outright." As written, that rule would push apps toward a guaranteed theme flash.
  • The stx new scaffold proposal ships a default stores/theme.ts containing useColorMode({ attribute: 'data-theme' }) — which, per #1788, applies the attribute and never the class, so every new project would start with both defects on day zero.

What would resolve it

Any one of:

  1. A config-level pre-paint hook — something stx can inline into <head> above the stylesheet, generated from the same storageKey/attribute/classNames options useColorMode already takes. This keeps one source of truth and removes the duplication the RFC is actually targeting.
  2. SSR-time resolution, where the server writes the class/attribute onto the root element from a cookie so no client script is needed pre-paint.
  3. Failing either — document explicitly that useColorMode is post-hydration only and that the inline snippet remains required, so the two proposals above get rewritten before they land.

I'd suggest (1): it is the only option that makes the strict-mode rule safe to enable, and it composes with #1788's fix rather than competing with it.

10 days ago

Implemented option (1) in 7d560e6 — the config-level pre-paint hook.

// stx.config.ts
export default {
  app: { colorMode: { storageKey: 'theme', attribute: 'data-theme' } },
}

stx inlines a render-blocking script as the first thing in <head>, above the stylesheets (a stylesheet ahead of it would block its execution, delaying the one thing it exists to do early). It takes the same options useColorMode takes and publishes them on window.__STX_COLOR_MODE__, so a bare useColorMode() recovers the configured key and attribute instead of repeating them at the call site.

That handshake is the load-bearing part. Without it the composable falls back to its own defaults, reads a different storage key, and undoes the theme on hydration — reintroducing the exact flash the script exists to prevent.

Deliberately mirrors useColorMode rather than improving on it. The boot script keeps the either/or attribute vs darkClass behaviour and the light | dark | auto vocabulary, so 'system' is still rejected. Both are #1788, and both sides have to change together — divergence here doesn't degrade gracefully, it means the boot script sets one thing and hydration immediately undoes it. So #1788 is still worth fixing; it now just has one extra site to change in lockstep, and the test suite fails if only one moves.

On the two proposals this was blocking:

  • The strict-mode no-bare-localstorage rule is now safe to enable — useColorMode genuinely does replace the theme-boot duplication, provided app.colorMode is declared.
  • The stx new scaffold should generate app.colorMode in stx.config.ts alongside the stores/theme.ts default. It still hits #1788's attribute-vs-class defect until that lands, so that ordering matters.

test/directives/color-mode-boot.test.ts asserts hydration changes neither the DOM nor the resolved decision across ten stored/system/config combinations. Verified it catches drift: reverting the handshake fails 5 cases, reverting the process.ts wiring fails both integration cases.

Also corrected the ambient types along the way — useColorMode was declared as returning { mode, setMode }, a shape neither implementation has ever returned.

Sign in to comment on this issue.