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:
- 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.
- 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:
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).
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.
server.ts — staticCacheControl(). 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.
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.htmlloads 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:vite buildrewritesdist/in place; during that window (or right after it), the served/cached HTML references hashedassets/*.cssthat no longer exist → 404 → unstyled paint. The PWAautoUpdateflow usually reloads shortly after, which is why the flash is brief.serveStatic()inserver.tssent noCache-Controlat 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.tscomment 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:
index.html— inline anti-FOUC boot splash.html { background:#1a1a1a; color-scheme:dark },body > * { visibility:hidden }, plus a#bootSplashoverlay (rising gold embers + warm glow, pure inline CSS,prefers-reduced-motionfallback, 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 stylesheeterror, reload once after 600ms (sessionStorage-guarded against reload loops during real outages).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.server.ts—staticCacheControl().no-cachefor HTML /sw.js/registerSW.js/ manifest;public, max-age=31536000, immutablefor hashedassets/*;max-age=3600for other static media.Verification
#bootSplashisdisplay:nonein 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 stylesheeterror.curl -I(htmlno-cache, assetsimmutable); 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
distswap (build to temp dir + rename) to close the mid-rebuild 404 window at the source.ETag/Last-Modifiedon HTML sono-cacherevalidation can 304.