Skip to content

Commit e69f588

Browse files
committed
fix: stop icons resolving twice when the fonts come from a Kit or CDN
Core compiles in the bundled FontAwesome Free stylesheets whichever icon source a forum has configured. Two of them bind the classic family to the bundled font at `:root`, and a Kit or a Pro CDN sets that same variable from a stylesheet that can only arrive later — so every icon on the page is resolved once against the bundled font and then again against the remote one. The second pass is what readers have been seeing as the icons flickering shortly after the page appears. Only the two font bindings are withheld now, and only when the fonts are not local. The glyph definitions stay: they say which character an icon is, name no font, and are the same whichever source supplies one. Brands stay too, since they bind their own family and so never compete with a Kit's. Withholding them is not sufficient on its own, because the glyph definitions reach their font through a chain that ends in a hardcoded `'Font Awesome 7 Free'` — a fallback that belongs to FontAwesome, not to us, and that wins as soon as nothing else in the chain is set. Remote sources therefore rebuild the bridge from that chain to the classic family and give the family a name no browser can resolve. Icons draw nothing at all until the real font arrives, keeping their box so that nothing moves when the glyphs appear: briefly absent rather than briefly wrong. A forum can also name a CDN or Kit and give no URL for it. That source can deliver nothing, so the bundled fonts are all it has and they are kept — reading such a forum as remote would leave it with no icons whatsoever rather than with icons that arrive late. Two smaller things while in here. The CDN stylesheet no longer carries a `<noscript>` counterpart: icons are rendered by the SPA, so a browser with scripting off has no icon elements on the page for it to style, and the fallback only ever bought such a browser an extra request. And the forced style help text now says outright that the bundled icons are Free, which covers only a few hundred icons outside the solid style, so forcing another style on them leaves most icons blank.
1 parent af3ee0d commit e69f588

7 files changed

Lines changed: 447 additions & 12 deletions

File tree

framework/core/less/common/Iconography.less

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,61 @@
1+
// Which glyph each icon is, and how icons size, stack and spin. This names no
2+
// font and is the same whichever source supplies one, so it is always needed —
3+
// without it an icon has nothing to draw.
14
@import (inline) "fontawesome.css";
5+
6+
// Brands bind their own family rather than the classic one, so they never
7+
// compete with a Kit's override and can stay in unconditionally.
28
@import (inline) "brands.css";
3-
@import (inline) "regular.css";
4-
@import (inline) "solid.css";
9+
10+
// These two bind the classic family to the bundled Free font at `:root`. A Kit,
11+
// or a Pro CDN, sets that same variable from a stylesheet that necessarily
12+
// arrives later, so keeping these when the icons are remote means every icon is
13+
// resolved twice — once against the bundled font, then again once the remote one
14+
// lands. Readers see that second pass as the icons flickering, and under a
15+
// forced style the bundled Free font is missing most of the glyphs, so the
16+
// first pass shows empty boxes.
17+
//
18+
// `@fa-bundled-fonts` comes from FontAwesome::needsBundledFonts().
19+
.fa-bundled-font-faces() when (@fa-bundled-fonts = true) {
20+
@import (inline) "regular.css";
21+
@import (inline) "solid.css";
22+
}
23+
.fa-bundled-font-faces();
24+
25+
// Withholding those two is not enough on its own. The glyph definitions resolve
26+
// their font as
27+
//
28+
// var(--fa-family, var(--fa-style-family, 'Font Awesome 7 Free'))
29+
//
30+
// a chain that never consults `--fa-family-classic`. Bridging the two was the
31+
// job of the stylesheets withheld above, so without a replacement the hardcoded
32+
// literal at the end wins and the icons resolve to the bundled font exactly as
33+
// before.
34+
//
35+
// So rebuild the bridge, and point it at a family no browser can resolve. Icons
36+
// draw nothing until the real font arrives, keeping their box so the layout does
37+
// not move when the glyphs appear — briefly absent rather than briefly wrong.
38+
//
39+
// The placeholder belongs on `:root` and the bridge on the classes, in that
40+
// order of specificity: a Kit publishes its font by setting `--fa-family-classic`
41+
// at `:root`, which then replaces the placeholder and flows through the bridge on
42+
// its own. Putting the placeholder on the classes instead would outrank the Kit
43+
// and leave the icons blank for good.
44+
.fa-remote-pending-font() when (@fa-bundled-fonts = false) {
45+
:root {
46+
--fa-family-classic: "FontAwesome Pending";
47+
}
48+
49+
.fa-solid,
50+
.fa-regular,
51+
.fa-classic,
52+
.fas,
53+
.far,
54+
.fa {
55+
--fa-family: var(--fa-family-classic);
56+
}
57+
}
58+
.fa-remote-pending-font();
559

660
// FontAwesome 7 (and its Kit JS) sets `display: var(--fa-display, inline-block)` on
761
// .fas/.far/.fab etc. When a Kit is loaded its dynamically-injected stylesheet lands

framework/core/locale/core.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ core:
5757
kit_url_label: Kit URL
5858
kit_url_help: Your FontAwesome Kit script URL (from kit.fontawesome.com)
5959
forced_style_label: Forced Icon Style
60-
forced_style_help: "Force every icon to a particular FontAwesome style, e.g. \"fa-duotone fa-light\" or \"fa-regular\". Replaces the style declared by core and extensions (brand icons are never changed). The style must be available in your icon source — most styles require FontAwesome Pro. Leave empty to disable."
60+
forced_style_help: "Force every icon to a particular FontAwesome style, e.g. \"fa-duotone fa-light\" or \"fa-regular\". Replaces the style declared by core and extensions (brand icons are never changed). Your icon source has to be able to supply the style: the bundled icons are FontAwesome Free, which only covers a few hundred icons outside the solid style, so forcing anything else on them leaves most icons blank. Every style is available with a FontAwesome Pro CDN or Kit. Leave empty to disable."
6161
config_override:
6262
label: Config Override
6363
help: FontAwesome settings are currently set in your config.php file and cannot be changed here.

framework/core/src/Foundation/FontAwesome.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ public function useLocalFonts(): bool
6666
return $this->source() === self::SOURCE_LOCAL;
6767
}
6868

69+
/**
70+
* Whether the bundled font files are what the icons will actually be drawn
71+
* from.
72+
*
73+
* This is not quite the same question as `useLocalFonts()`. A forum can name
74+
* a CDN or a Kit and give no URL for it, in which case that source cannot
75+
* deliver anything and the bundled fonts are all there is. Treating such a
76+
* forum as remote would leave it with no icons at all, rather than with
77+
* icons that arrive late.
78+
*/
79+
public function needsBundledFonts(): bool
80+
{
81+
if ($this->useLocalFonts()) {
82+
return true;
83+
}
84+
85+
if ($this->useCdn()) {
86+
return $this->cdnUrl() === null;
87+
}
88+
89+
if ($this->useKit()) {
90+
return $this->kitUrl() === null;
91+
}
92+
93+
// An unrecognised source names nothing we can load, so the bundled
94+
// fonts remain the only ones available.
95+
return true;
96+
}
97+
6998
public function useCdn(): bool
7099
{
71100
return $this->source() === self::SOURCE_CDN;

framework/core/src/Frontend/FrontendServiceProvider.php

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,12 @@ public function register(): void
122122
}
123123
// Load asynchronously — FA icons are only rendered after JS boots the
124124
// SPA, so there is no FOUC risk from deferring this stylesheet.
125-
// The <noscript> fallback covers JS-disabled browsers.
126-
$escaped = e($cdnUrl);
127-
$document->head[] = '<link rel="preload" href="'.$escaped.'" as="style" crossorigin="anonymous" onload="this.onload=null;this.rel=\'stylesheet\'">'
128-
.'<noscript><link rel="stylesheet" href="'.$escaped.'" crossorigin="anonymous"></noscript>';
125+
//
126+
// Deliberately without a <noscript> counterpart: the icons are
127+
// rendered by the SPA, so a browser with scripting off has no icon
128+
// elements on the page for this stylesheet to style. The fallback
129+
// only ever bought such a browser an extra request.
130+
$document->head[] = '<link rel="preload" href="'.e($cdnUrl).'" as="style" crossorigin="anonymous" onload="this.onload=null;this.rel=\'stylesheet\'">';
129131
}
130132
} elseif ($fontAwesome->useKit()) {
131133
$kitUrl = $fontAwesome->kitUrl();
@@ -247,7 +249,16 @@ function (Container $container) {
247249
$this->container->singleton(
248250
'flarum.less.custom_variables',
249251
function (Container $container) {
250-
return [];
252+
return [
253+
// Whether to compile in the bundled Free font bindings. They
254+
// name a font at `:root`, so leaving them in when the icons
255+
// come from a Kit or CDN means every icon is resolved once
256+
// against the bundled font and again against the remote one
257+
// — which is what shows up as the icons flickering.
258+
'fa-bundled-fonts' => function () use ($container) {
259+
return $container->make(FontAwesome::class)->needsBundledFonts() ? 'true' : 'false';
260+
},
261+
];
251262
}
252263
);
253264

framework/core/tests/integration/frontend/FontAwesomeLoadingTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ public function fontawesome_cdn_loads_css_instead_of_local_fonts()
6969
// Should load CDN CSS asynchronously (preload + onload swap)
7070
$this->assertStringContainsString('<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" as="style" crossorigin="anonymous" onload="this.onload=null;this.rel=\'stylesheet\'">', $body);
7171

72-
// Should include noscript fallback
73-
$this->assertStringContainsString('<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous"></noscript>', $body);
72+
// No `<noscript>` counterpart. Icons are rendered by the SPA, so with
73+
// scripting off the page contains no icon elements for a stylesheet to
74+
// style — the fallback only ever cost a request.
75+
$this->assertStringNotContainsString('<noscript><link rel="stylesheet"', $body);
7476

7577
// Should not contain local font preloads
7678
$this->assertStringNotContainsString('fa-solid-900.woff2', $body);
@@ -164,8 +166,8 @@ public function config_override_takes_precedence_over_database_settings()
164166
// Should load config CDN CSS asynchronously (preload + onload swap)
165167
$this->assertStringContainsString('<link rel="preload" href="https://config.example.com/fontawesome.css" as="style" crossorigin="anonymous" onload="this.onload=null;this.rel=\'stylesheet\'">', $body);
166168

167-
// Should include noscript fallback
168-
$this->assertStringContainsString('<noscript><link rel="stylesheet" href="https://config.example.com/fontawesome.css" crossorigin="anonymous"></noscript>', $body);
169+
// No `<noscript>` counterpart — see the CDN test above.
170+
$this->assertStringNotContainsString('<noscript><link rel="stylesheet"', $body);
169171

170172
// Should not contain local font preloads
171173
$this->assertStringNotContainsString('fa-solid-900.woff2', $body);

0 commit comments

Comments
 (0)