ReviewOS

stacks/stx

signals: x-cloak / :if FOUC — runtime injects the cloak <style> too late; ship it inline at SSR + auto-stamp x-cloak on conditional elements

#1736
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

Bug

Every page reload with reactive conditional directives (:if, :else, :else-if, x-if, x-else, x-else-if, :show, x-show) flashes false-branch / hidden content before the runtime hides it. The intent is x-cloak should fix this, but the current implementation can't — and even if it could, the per-element opt-in is a recurring footgun.

Repro

Any template with a reactive :if chain:

<script client>
  const isAuthenticated = derived<boolean>(() => !!token())
</script>

<div :if="isAuthenticated()">Welcome back</div>
<div :else>
  <a href="/login">Sign in</a> to continue
</div>

Hard-reload the page (incognito tab, throttled 3G in DevTools). The Welcome banner AND the Sign-in banner both render for ~100-300ms before only the active one stays. Same for modals (:if="modalOpen()" flashing open on mount), skeleton loaders (:if="!loaded()" flashing alongside the loaded content), bell badges, etc.

Adding x-cloak to every conditional element doesn't fix it. I tested with a clean template, sprinkled x-cloak on the :if/:else divs — same flash.

Root cause

signals.ts (line 115-118 in current main) injects the cloak rule inside the runtime script:

// Inject x-cloak CSS to prevent FOUC (Flash of Unstyled Content)
// Elements with x-cloak are hidden until the runtime removes the attribute after mount
const cloakStyle = document.createElement('style')
cloakStyle.textContent = '[x-cloak] { display: none !important; }'
document.head.appendChild(cloakStyle)

Page-load timeline:

  1. Browser parses HTML, applies inline <style> blocks in <head> (crosswind preflight + utilities)
  2. Browser paints body — all conditional elements are visible, x-cloak does nothing because no rule for it has been parsed yet
  3. JS runs → runtime executes → cloakStyle appended to head → browser applies the rule
  4. Runtime processes :if/:else chains → removes attributes from active branch, detaches false branches
  5. User sees the correct UI

The flash is the window between (2) and (3-4). x-cloak can't help because step (3) is where its rule gets defined. Telling adopters to "use x-cloak to prevent FOUC" is misleading — by the time x-cloak's CSS exists, the FOUC has already happened.

Why the per-element opt-in is the wrong shape

Even if the rule shipped early enough, requiring x-cloak on every conditional element is:

  • Easy to forget — every new :if is a fresh chance to ship an unintended flash. The framework knows where the directives are; the dev shouldn't have to.
  • Inconsistent with the rest of the directive family — :if already auto-handles detachment, cleanup, scope disposal. Asking the dev to add a separate x-cloak attribute alongside breaks the "directives are declarative" promise.
  • Multiplies with #1734 — the new :if / :else-if / :else chains mean a single conditional often becomes 3+ elements. Three more places to forget x-cloak.

The framework should opt-in by default and opt-out by exception, the same way React SSR / Vue SSR / Nuxt do.

Prior art

  • React/Vue SSR (Next, Remix, Nuxt): Server evaluates the conditional at render and emits ONLY the active branch. Zero FOUC by construction because false branches never enter the DOM. This is the gold standard but requires expression analysis to know what's server-resolvable.
  • Alpine.js (closest analog to stx's x- directives): Ships the cloak rule in their docs as a one-liner you put in <head> once, then add x-cloak per-element. Same footgun as stx, just documented as "this is your responsibility."
  • Vue SFC v-if/v-else: Compiles at build to a render function; the runtime conditionally invokes the branch. No flash because the framework owns the render path entirely.

Proposed fix (incremental, ships in one diff)

A. SSR injects the cloak rule into the generated <head>

generateSpaShell (or equivalent in bun-plugin-stx) emits a small <style> block as part of the head:

<style data-stx-cloak>
[x-cloak] { display: none !important; }
</style>

Browser parses this with the rest of the head — rule is live by step (2) in the timeline above. No script execution required.

B. SSR auto-stamps x-cloak on every element with a reactive conditional directive

During the same pass that processes :if/x-if/etc., scan for these attributes on the element:

:if, :else, :else-if, :show, @if, @show
x-if, x-else, x-else-if, x-show

If any present, also add x-cloak. The runtime already strips x-cloak at the moment it processes the element (lines 2192-2193, 2200-2201, 2533-2534, 2666-2667, etc.) — no runtime change needed. The only change is at the SSR processor.

C. Compat

  • Hand-written x-cloak still works (it's just additive — the SSR pass would set it if not already present).
  • Build-time @if/