Skip to content

Commit 786ed7c

Browse files
committed
[2.x] fix(perf): replace async-CSS dual path with render-blocking stylesheet
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.
1 parent d01c320 commit 786ed7c

3 files changed

Lines changed: 41 additions & 30 deletions

File tree

framework/core/src/Frontend/Document.php

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,28 @@ class Document implements Renderable
9494
public ?bool $hasNextPage = null;
9595

9696
/**
97-
* An array of strings to append to the page's <head>.
97+
* An array of strings to append to the page's <head>, rendered *after*
98+
* the forum/admin stylesheet so that user-supplied <style>/<link> blocks
99+
* correctly override core CSS.
98100
*/
99101
public array $head = [];
100102

101103
/**
102-
* Inline critical CSS emitted before the async stylesheet links.
103-
* Populated by FrontendServiceProvider to prevent a flash of unstyled content.
104+
* An array of strings to render in <head> *before* the forum/admin
105+
* stylesheet. Use this for hints and scripts that must take effect
106+
* before first paint — preconnect hints to additional origins, an
107+
* inline script that sets data-theme on <html>, etc.
108+
*
109+
* Anything that depends on CSS variables, computed styles, or that
110+
* should be able to override the forum stylesheet belongs in $head.
111+
*/
112+
public array $preHead = [];
113+
114+
/**
115+
* Inline critical CSS emitted before the main stylesheet link.
116+
* Populated by FrontendServiceProvider to give the browser a
117+
* theme-accurate body background to paint while the main stylesheet
118+
* is being fetched.
104119
*/
105120
public string $criticalCss = '';
106121

@@ -303,25 +318,18 @@ protected function makePreconnects(): array
303318

304319
protected function makeHead(): string
305320
{
306-
// On warm visits (CSS already cached), a tiny inline script injects blocking
307-
// <link rel="stylesheet"> tags synchronously before first paint — no FOUC, no
308-
// network round-trip. On cold visits the sessionStorage keys are absent so the
309-
// script exits immediately and the async preload path below takes over.
310-
// Versioned URLs act as natural cache-busters: a new deploy changes the URL,
311-
// the old sessionStorage key doesn't match, and the async path runs once more.
321+
// The forum/admin stylesheet is render-blocking (parser-discovered
322+
// <link rel="stylesheet">), so anything that must be in effect *before*
323+
// first paint — preconnect hints, and anything pushed to $preHead by
324+
// content callbacks (e.g. the inline data-theme script) — precedes it.
325+
// Everything else — extension head content, JS preloads, meta tags,
326+
// polyfills — comes after so it doesn't delay paint and so user
327+
// overrides like custom <style> blocks correctly win the cascade.
312328
$head = $this->makePreconnects();
329+
$head = array_merge($head, $this->preHead);
313330

314-
if (! empty($this->css)) {
315-
$cssJson = json_encode(array_values($this->css), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG);
316-
$head[] = '<script>(function(){var s='.$cssJson.';if(s.every(function(h){return sessionStorage.getItem("css:"+h);})){s.forEach(function(h){var l=document.createElement("link");l.rel="stylesheet";l.href=h;document.head.appendChild(l);});}Object.keys(sessionStorage).forEach(function(k){if(k.indexOf("css:")===0&&s.indexOf(k.slice(4))===-1){sessionStorage.removeItem(k);}});})();</script>';
317-
}
318-
319-
// Async preload path for cold visits. The onload updates sessionStorage so the
320-
// next page load can take the fast synchronous path above.
321331
foreach ($this->css as $url) {
322-
$escaped = e($url);
323-
$head[] = '<link rel="preload" href="'.$escaped.'" as="style" fetchpriority="high" onload="sessionStorage.setItem(\'css:\'+this.href,\'1\');this.onload=null;this.rel=\'stylesheet\'">'
324-
.'<noscript><link rel="stylesheet" href="'.$escaped.'"></noscript>';
332+
$head[] = '<link rel="stylesheet" href="'.e($url).'" fetchpriority="high">';
325333
}
326334

327335
if ($this->page) {

framework/core/src/Frontend/FrontendServiceProvider.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,18 @@ public function register(): void
8686
$frontend->content(function (Document $document) use ($container) {
8787
$default_preloads = $container->make('flarum.frontend.default_preloads');
8888

89-
// CSS files are preloaded via the async <link rel="preload" as="style"> tags
90-
// that makeHead() now emits directly — no need to duplicate them here.
91-
// JS files still get explicit preload hints so the browser fetches them early.
89+
// The forum/admin stylesheet blocks paint (parser-discovered <link
90+
// rel="stylesheet">), so the next thing on the critical path is forum.js
91+
// booting the SPA. Preload JS at high priority so it lands while CSS is
92+
// still being fetched, rather than waiting for the body parser to discover
93+
// the <script> tag.
9294
$js_preloads = [];
9395

9496
foreach ($document->js as $url) {
9597
$js_preloads[] = [
9698
'href' => $url,
9799
'as' => 'script',
98-
'fetchpriority' => 'low',
100+
'fetchpriority' => 'high',
99101
];
100102
}
101103

@@ -153,10 +155,11 @@ public function register(): void
153155

154156
// Inline script that sets data-theme on <html> before first paint so that
155157
// the critical CSS block can apply the correct background colour without a
156-
// flash in dark mode. Uses the forum-level default; per-user preference is
157-
// applied by JS after boot (acceptable tradeoff).
158+
// flash in dark mode. Goes into $preHead so it executes before the main
159+
// stylesheet link is processed. Uses the forum-level default; per-user
160+
// preference is applied by JS after boot (acceptable tradeoff).
158161
$forumColorScheme = $settings->get('color_scheme') ?? 'auto';
159-
$document->head[] = '<script>(function(){var s='.json_encode($forumColorScheme).';'
162+
$document->preHead[] = '<script>(function(){var s='.json_encode($forumColorScheme).';'
160163
.'if(s==="auto")s=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";'
161164
.'document.documentElement.setAttribute("data-theme",s)})()</script>';
162165

framework/core/views/frontend/app.blade.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
<meta charset="utf-8">
55
<title>{{ $title }}</title>
66

7-
{{-- Minimal critical CSS: prevents a flash of unstyled content while the main --}}
8-
{{-- stylesheet loads asynchronously. Content is computed server-side so the dark --}}
9-
{{-- background matches the forum's actual secondary colour. See FrontendServiceProvider. --}}
7+
{{-- Minimal critical CSS: gives the browser a theme-accurate body background to paint --}}
8+
{{-- as soon as the document starts, before the stylesheet has loaded. Content is --}}
9+
{{-- computed server-side so the dark background matches the forum's actual secondary --}}
10+
{{-- colour. See FrontendServiceProvider. --}}
1011
@if($criticalCss)<style>{!! $criticalCss !!}</style>@endif
11-
1212
{!! $head !!}
1313
</head>
1414

0 commit comments

Comments
 (0)