Skip to content

FOUC on load: white flash of unstyled form elements when stale/mid-rebuild HTML references missing hashed CSS #91

Description

@ashwin-pc

Symptom

On page load there is sometimes a brief flash where no CSS is applied: white background, native browser form widgets (the "Ask pi…" textarea, Loading models… off, ctx —, bare buttons), before the app appears styled.

Root cause

The built index.html loads CSS via render-blocking <link> tags, so a healthy load can never paint unstyled (verified with a CDP screencast: cold + warm loads show blank → styled, no unstyled frame). The flash appears only when a stylesheet request fails, which unblocks rendering with zero styles applied:

  1. Load racing a rebuild: vite build rewrites dist/ in place; during that window (or right after it), the served/cached HTML references hashed assets/*.css that no longer exist → 404 → unstyled paint. The PWA autoUpdate flow usually reloads shortly after, which is why the flash is brief.
  2. No cache headers: serveStatic() in server.ts sent no Cache-Control at all — stale HTML could outlive its hashed assets, and (as a side effect) the ~1MB critical asset path was refetched on every load.

Note: the service worker deliberately does not precache HTML (vite.config.ts comment re: auth-proxy redirects), so HTML always comes from the network/HTTP cache — consistency between HTML and assets is entirely the server's job.

Fix (implemented downstream)

Implemented and verified in a downstream working copy; PR to follow. Three parts:

  1. index.html — inline anti-FOUC boot splash. html { background:#1a1a1a; color-scheme:dark }, body > * { visibility:hidden }, plus a #bootSplash overlay (rising gold embers + warm glow, pure inline CSS, prefers-reduced-motion fallback, muted "restoring interface…" hint after 2.5s). Fully inline by design: when CSS is stale, any external asset could be equally stale. Also a one-shot heal script: on stylesheet error, reload once after 600ms (sessionStorage-guarded against reload loops during real outages).
  2. src/styles/base.css — dismissal pairing. body > * { visibility:visible } + #bootSplash { display:none } — the real stylesheet reveals the app and removes the splash the instant it applies.
  3. server.tsstaticCacheControl(). no-cache for HTML / sw.js / registerSW.js / manifest; public, max-age=31536000, immutable for hashed assets/*; max-age=3600 for other static media.

Verification

  • Repro (Playwright, CSS requests aborted): before = white unstyled form flash; after = dark ember splash, no white frame.
  • Self-heal e2e: CSS fails on first load → splash ~600ms → one auto-reload → fully styled app.
  • Healthy loads unchanged: #bootSplash is display:none in the first painted frame; A/B (splash markup present vs stripped, symmetric proxying, 5 runs each, throttled to ~½s total): FCP median 188ms vs 188ms, time-to-new-session 569ms vs 573ms (noise). The heal timer only arms on stylesheet error.
  • Cache headers verified via curl -I (html no-cache, assets immutable); warm loads are faster than before since hashed assets now cache.

Screenshots/video of before/after and the load filmstrip exist in the downstream workspace (.pi/web/artifacts/fouc/); can attach to the PR.

Possible follow-ups

  • Atomic dist swap (build to temp dir + rename) to close the mid-rebuild 404 window at the source.
  • Consider ETag/Last-Modified on HTML so no-cache revalidation can 304.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingimplemented-downstreamImplemented in a downstream fork; track when upstream lands the same change

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions