Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions framework/core/src/Frontend/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,28 @@ class Document implements Renderable
public ?bool $hasNextPage = null;

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

/**
* Inline critical CSS emitted before the async stylesheet links.
* Populated by FrontendServiceProvider to prevent a flash of unstyled content.
* An array of strings to render in <head> *before* the forum/admin
* stylesheet. Use this for hints and scripts that must take effect
* before first paint — preconnect hints to additional origins, an
* inline script that sets data-theme on <html>, etc.
*
* Anything that depends on CSS variables, computed styles, or that
* should be able to override the forum stylesheet belongs in $head.
*/
public array $preHead = [];

/**
* Inline critical CSS emitted before the main stylesheet link.
* Populated by FrontendServiceProvider to give the browser a
* theme-accurate body background to paint while the main stylesheet
* is being fetched.
*/
public string $criticalCss = '';

Expand Down Expand Up @@ -303,25 +318,18 @@ protected function makePreconnects(): array

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

if (! empty($this->css)) {
$cssJson = json_encode(array_values($this->css), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG);
$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>';
}

// Async preload path for cold visits. The onload updates sessionStorage so the
// next page load can take the fast synchronous path above.
foreach ($this->css as $url) {
$escaped = e($url);
$head[] = '<link rel="preload" href="'.$escaped.'" as="style" fetchpriority="high" onload="sessionStorage.setItem(\'css:\'+this.href,\'1\');this.onload=null;this.rel=\'stylesheet\'">'
.'<noscript><link rel="stylesheet" href="'.$escaped.'"></noscript>';
$head[] = '<link rel="stylesheet" href="'.e($url).'" fetchpriority="high">';
}

if ($this->page) {
Expand Down
17 changes: 10 additions & 7 deletions framework/core/src/Frontend/FrontendServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,18 @@ public function register(): void
$frontend->content(function (Document $document) use ($container) {
$default_preloads = $container->make('flarum.frontend.default_preloads');

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

foreach ($document->js as $url) {
$js_preloads[] = [
'href' => $url,
'as' => 'script',
'fetchpriority' => 'low',
'fetchpriority' => 'high',
];
}

Expand Down Expand Up @@ -153,10 +155,11 @@ public function register(): void

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

Expand Down
8 changes: 4 additions & 4 deletions framework/core/views/frontend/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<meta charset="utf-8">
<title>{{ $title }}</title>

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

{!! $head !!}
</head>

Expand Down
Loading