Skip to content

[2.x] fix(perf): replace async-CSS dual path with render-blocking stylesheet#4664

Merged
imorland merged 1 commit into
2.xfrom
im/fix-css-fouc-render-blocking
May 14, 2026
Merged

[2.x] fix(perf): replace async-CSS dual path with render-blocking stylesheet#4664
imorland merged 1 commit into
2.xfrom
im/fix-css-fouc-render-blocking

Conversation

@imorland
Copy link
Copy Markdown
Member

Summary

PR #4561 introduced a sessionStorage-driven warm CSS cache plus async preload cold path, intended to eliminate FOUC while keeping CSS off the critical path. In practice the strategy was broken at the spec level: a JS-injected <link rel="stylesheet"> doesn't render-block the way a parser-discovered one does. The result was a visible flash of unstyled content on every cold tab (sessionStorage is per-tab) and intermittently on warm visits too, especially when assets are served from a cross-origin host (S3+CloudFront the most visible case).

This replaces the dual path with the standard <link rel="stylesheet">. Parser-discovered, render-blocking, ~single-digit ms on a CDN, no flash. The inline critical CSS stays as a safety net for the brief window before the stylesheet is parsed.

Changes

  • Document::makeHead() emits a plain <link rel="stylesheet" href="…" fetchpriority="high"> per CSS URL. The sessionStorage inline script, the async preload tag, and the <noscript> fallback are gone.
  • JS preloads bump from fetchpriority="low" to "high" — the stylesheet is now the first paint blocker, so the SPA boot (forum.js) is the next critical-path item.
  • New Document::$preHead[] field for content that must render before the main stylesheet — used by the inline data-theme script so dark-mode users don't briefly see a light background if the browser paints before the stylesheet applies. $head[] semantics are unchanged: items still render after the stylesheet, preserving cascade order for extension-supplied <style>/<link> overrides.
  • Minor blade comment cleanup in app.blade.php.

For extension authors

  • The HTML emitted by Document::makeHead() has changed. Test snapshots that captured the previous async-preload markup will need regenerating.
  • Document::$head[] semantics are unchanged — items continue to render after the main stylesheet, so admin-supplied custom CSS still wins the cascade.
  • A new Document::$preHead[] is available for the rare case where head content genuinely needs to be in effect before first paint (e.g. an inline script setting attributes on <html> that critical CSS keys off). Existing extensions don't need to change.

Test plan

  • Frontend integration tests pass (13/13).
  • Verified rendered head on a live forum: stylesheet is parser-discovered, JS preloads are fetchpriority="high", data-theme inline script precedes the stylesheet.
  • Verified backward compatibility with the ianm/html-head extension: inserted a test <style> row, confirmed it renders after the main stylesheet and would correctly override forum.css. No regression for admin-customised head content.
  • FOUC visually confirmed gone on dev forum (S3-like asset path), warm and cold visits.

…lesheet

The dual-path strategy from #4561 (sessionStorage warm cache + async
preload cold path) relied on JS-injected <link rel="stylesheet"> elements
blocking paint, which they do not by spec. The result was a flash of
unstyled content on cross-origin asset hosts (S3 / CloudFront most
visibly), on every cold tab, and intermittently on warm visits too.

- makeHead() now emits a standard <link rel="stylesheet"> for forum/admin
  CSS. Parser-discovered, render-blocking, ~single-digit ms on a CDN.
- The sessionStorage warm-path script and the noscript fallback are
  removed; the inline critical CSS stays as a safety net so the body
  has a theme-accurate background while the stylesheet is in flight.
- JS preloads switch from fetchpriority="low" to "high" — now that the
  stylesheet blocks paint, the SPA boot is the next critical-path item.
- New Document::$preHead bucket for content that must render before the
  stylesheet (used by the inline data-theme script so dark-mode users
  never see a light-flash if the browser paints before CSS arrives).
  Document::$head[] semantics are unchanged: items still render after
  the stylesheet, so admin-supplied <style>/<link> overrides correctly
  win the cascade.
@imorland imorland requested a review from a team as a code owner May 14, 2026 22:04
@imorland imorland added this to the 2.0.0-rc.2 milestone May 14, 2026
@imorland imorland merged commit 5cde2f4 into 2.x May 14, 2026
25 checks passed
@imorland imorland deleted the im/fix-css-fouc-render-blocking branch May 14, 2026 22:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant