ReviewOS

stacks/stx

Color-mode boot script is emitted after the store/composable bundles, so useColorMode() in a store silently falls back to default storageKey and attribute

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

Color-mode boot script is emitted after the store and composable bundles, so useColorMode() in a store silently gets default config

Verified against the stx fork at main (0dcb6fbd0e), built from source, in a real app on Chromium via CDP.

Symptom

A store that calls useColorMode() writes to the wrong storage key and never sets the configured attribute. With this in stx.config.ts:

app: {
  colorMode: {
    storageKey: 'bughq_theme',
    attribute: 'data-theme',
    darkClass: null,
    initialMode: 'auto',
  },
}

and a store:

export const useTheme = defineStore('theme', () => {
  const cm = useColorMode()          // no options — by design, config is central
  function toggle() { cm.set(cm.mode === 'dark' ? 'light' : 'dark') }
  return { cm, toggle }
})

calling toggle() produces:

document.documentElement.getAttribute('data-theme')  →  unchanged
localStorage.getItem('bughq_theme')                  →  null
localStorage.getItem('stx-color-mode')               →  'light'   ← default key

The theme appears to do nothing, and the user's stored preference is written somewhere the boot script will never read on the next load.

Cause

useColorMode reads its configuration from window.__STX_COLOR_MODE__, which the pre-paint boot script publishes. But the boot script is emitted after the store and composable bundles. Byte offsets within <head> of a rendered page:

byte 2260   <script data-stx-runtime src="/_stx/runtime.js">   (external, render-blocking)
byte 2318   <script data-stx-stores>          <-- calls useColorMode()
byte 3656   <script data-stx-composables>
byte 5285   <script data-stx-color-mode-boot> <-- publishes __STX_COLOR_MODE__
byte 6344   <link rel="stylesheet" …>

So at the moment the store runs, window.__STX_COLOR_MODE__ is undefined, and

var storageKey = pick(options.storageKey, boot && boot.storageKey, 'stx-color-mode');
var attribute  = pick(options.attribute,  boot && boot.attribute,  null);

falls through to the defaults — 'stx-color-mode' and attribute: null. applyDOM then skips the attribute entirely because attribute is null.

window.__STX_COLOR_MODE__ does exist by the time you inspect it from the console, which makes this look like it should work. It is purely an ordering problem.

This contradicts the module's own contract

color-mode-boot.d.ts states:

Insert the boot script as the first thing in <head>. Placement matters twice over: the script must run before the browser paints, and a stylesheet <link> above it would block its execution (CSS blocks JS), delaying the very thing it exists to make early.

It is not first. It is fifth.

<script data-stx-runtime src="/_stx/runtime.js"> has no async/defer, so the parser must download and execute the whole runtime bundle before reaching the boot script. That is a larger delay than the stylesheet case the doc already guards against, and it defeats the "run before paint" purpose even for apps that use no stores at all.

Suggested fix

Emit the color-mode boot script first in <head>, ahead of the runtime, stores and composables bundles — which is what injectColorModeBootScript already documents. That fixes both problems at once: stores see the published config, and the theme lands before any bundle has to download.

Workaround for anyone hitting this now

Pass the options explicitly at the call site instead of relying on the published config:

const cm = useColorMode({ storageKey: 'bughq_theme', attribute: 'data-theme', darkClass: null })

This duplicates the configuration, which is exactly what app.colorMode exists to avoid — but it is correct regardless of script order.

10 days ago

Fixed in bfd6332c97.

Your diagnosis was right, and the mechanism is worth recording: injectColorModeBootScript does put the script first — it gets overtaken afterwards. placeSignalsRuntimeBeforeScripts relocates the runtime ahead of the first <script> on the page, which is the boot script, and the store and composable bundles then anchor themselves to the runtime tag. Hence fifth.

Rather than order those injectors against each other, the fix re-asserts the result once after all of them have run. Pairwise ordering holds only until the next injector is added.

Two things beyond the report:

  • <meta charset> had to move too. The SEO meta block already displaced it to the end of <head>, and hoisting the ~800-byte boot script above it would have pushed the encoding declaration out of the spec's first-1024-byte budget. Emitted head is now <head> → charset → boot script → everything else.
  • Not adding defer to the runtime tag. Everything emitted alongside it is an inline script and therefore runs during parsing, before any deferred external script would — deferring the runtime would leave defineStore/state/derived undefined when the stores bundle evaluates. With the boot script genuinely first, the render-blocking runtime no longer delays it, which was the part that mattered.

Two gaps left, both pre-existing and separate: stx dev --native prepends at head-open and still lands above the boot script (an inline <style>, so it doesn't block scripts), and shell mode composes its own <head> with no boot script at all. Say the word if either bites you and I'll file them.

Your workaround (passing options at the call site) is no longer needed.

Sign in to comment on this issue.