Pantrix UI

A typeface that arrives late on the web

Why, on Compose Multiplatform's web target, an app's own typeface arrives a moment late, how we measured it and how we cured it. The problem is not Pantrix's own: it is seen the same way in every Compose Multiplatform project that has a web target and uses its own font file.

1. The problem

Symptom

On web pages this is called FOUT (flash of unstyled text).

Cause

In Compose Multiplatform a font file is read with Font(Res.font.…). This function does two separate things by platform (Compose Multiplatform 1.12.1, components-resources):

PlatformReadingIn the first frame
Android, iOS, desktopSynchronous: the file is read the moment it is asked for (ResourceState.blocking.kt)The typeface is ready
Web (js, wasmJs)Asynchronous: the file is downloaded with fetch (ResourceState.web.kt)There is no typeface

On the web Font(…) returns a fallback until the file arrives: the Roboto that Skiko carries inside it (FontResources.skiko.kt, defaultFont). When the file arrives the value it returns changes, everything that reads it is set up again and every text is laid out again. Three details make the problem bigger:

  1. The reading starts late. Font(…) is a composable: the download starts only when the app is first being set up, that is, after the Wasm has been downloaded and compiled.

  2. Every weight is a separate font. The cache's key is the file's path, its weight, its style and its axis settings. A single variable font registered in seven weights is read seven times and can arrive at seven separate moments: the theme is set up from the start several times.

  3. It is asked for again at every load. The resources' cache in the browser (the Cache API) is reset at every page load (ResourceWebCache.web.kt); the file is given by the browser's own HTTP cache.

Measurement

The production bundle (wasmJsBrowserDistribution) was opened from a local server; the times were read with a measuring script added to the page (see section 6). The numbers are milliseconds after the page opened.

EventBefore the fixAfter the fix
The typeface files were asked for408 (after the app was set up; only the theme's file)71 (while the code downloads, <link rel="preload">; all fifteen files)
All the fonts were read440274
The app's first frame401, in the fallback typeface456, in its own typeface
The letters changedAbout 500Never
Another typeface was chosen in "Settings"Seven files are asked for at that moment; the fallback typeface until they arriveNo new request; it changes in one frame

Locally there is no network latency, and the difference is a hundred milliseconds; on a real network it lasts as long as the typeface is late. The first frame itself took between 175 and 240 ms in both measurements: waiting for the typeface did not delay the opening, because the files had come down long before that frame was set up.

2. The solution: four pieces

PieceWhat it gives
1Download early. <link rel="preload"> in index.htmlThe file downloads while the app's code downloads; when the app asks for it, it is in the browser's cache
2Read first, draw after. Put the app behind a gateThe app draws its first frame in its own typeface; the letters do not change
3Keep the loading mark. Set up the app in its own box, not in the bodyWhile waiting, the page's loading mark shows, not an empty page
4Stay with the old one when the typeface changes. Change once, when the new one arrivesChanging the typeface while the app runs shows no fallback typeface either

The second piece is the heart of the work, and rests on a property of Compose: a font read stays in memory, and the first setup that asks for it later finds it at once. So it is enough to ask for the typeface somewhere first (preloadFont) and to set up the app after that; nothing changes in the app itself.

3. With Pantrix

The library gives the Kotlin side of the four pieces (com.pantrixui.foundation.token.typography):

APIWhat it does
PantrixFont(resource, weight, style, variationSettings)A font: the file in the resources, and the weight and style the family registers it at. variationSettings is its place on a variable font's axes; if it is not given, it is what the weight and the style say. Reading ahead asks with the same settings
PantrixTypeface(vararg fonts)A typeface, with its font files. PantrixTypeface.Default is the library's own typeface
rememberPantrixTypography(typeface)Pantrix's scale, in that typeface. While the typeface changes it stays with the old one, and changes once when the new one arrives
rememberPantrixFontFamily(typeface)Only the family (FontFamily)
areTypefacesLoaded(vararg typefaces)Whether all the fonts of the typefaces given have been read. Asking starts the reading
isPantrixTypefaceLoaded()The same, for the library's own typeface
PantrixTypefaceGate(loaded, timeoutMillis, onShown, content)Draws the content after the typeface has been read; waits at most timeoutMillis

On Android, iOS and the desktop they all answer at once: the same code is written on every platform, and it waits only on the web.

3.1. Say the typeface once

// commonMain
val Brand = PantrixTypeface(
    PantrixFont(Res.font.brand_regular),
    PantrixFont(Res.font.brand_medium, FontWeight.Medium),
    PantrixFont(Res.font.brand_bold, FontWeight.Bold)
)

@Composable
fun App() {
    PantrixTheme(typography = rememberPantrixTypography(Brand)) {
        // …
    }
}

The fonts are counted in one place; the family, and whether they have been read in the browser, are both asked of that list. If the two are counted in separate places, one day they will not match (see section 5, "The same key").

An app that uses only the library's own typeface need not say anything: PantrixTheme { … } is enough.

3.2. The Web entry

// webMain
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    // On a page without the box (an old index.html the browser kept), it is set up in the body.
    val box = "app".takeIf { document.getElementById(it) != null }
    ComposeViewport(viewportContainerId = box) {
        PantrixTypefaceGate(
            loaded = areTypefacesLoaded(Brand),
            onShown = { document.getElementById("loading")?.remove() }
        ) {
            App()
        }
    }
}

For the library's own typeface loaded is not given: PantrixTypefaceGate(onShown = …) { App() }.

3.3. The page

<head>
    <!-- The typeface is downloaded while the app's code downloads. Without `crossorigin` the browser does not
         count this download as the app's own request, and the file comes down twice. -->
    <link rel="preload" as="fetch" type="font/ttf" crossorigin
          href="composeResources/<package>.generated.resources/font/brand_regular.ttf">
</head>
<body>
    <!-- The loading mark: it stands over the app until the app's first frame is drawn. -->
    <div id="loading">…</div>
    <!-- The box the app is set up in. -->
    <div id="app"></div>
    <script type="application/javascript" src="webApp.js"></script>
</body>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; }

#app { width: 100%; height: 100%; }

#loading {
    position: fixed;
    inset: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}

The <package> in the address is the package of the generated Res class of the module that carries the resources. The address of the library's own typeface is composeResources/com.pantrixui.foundation.generated.resources/font/plus_jakarta_sans.ttf. To be sure, build the bundle once and look in the build/dist/…/composeResources folder.

3.4. A typeface that changes while the app runs

rememberPantrixTypography(typeface) solves this itself: when typeface changes, it goes on giving the old typography until the new one has been read, and gives the new one once, when all of it has arrived.

val typeface = if (settings.brand) Brand else PantrixTypeface.Default
PantrixTheme(typography = rememberPantrixTypography(typeface)) { /* … */ }

Between the choice and the change, as much time passes as the files take to download. If that too is not wanted, the typefaces are read ahead: they are all given to the gate together, and the page downloads them all ahead. The sample app in this repository does so, because it has a page that shows three typefaces side by side:

PantrixTypefaceGate(loaded = areTypefacesLoaded(PantrixTypeface.Default, PlusJakartaSans, Poppins)) { App() }

The price is the bytes downloaded: in the sample app fifteen files, 2.0 MB. For a product the right thing is mostly to read ahead only the first screen's typeface, and to leave the others to the change.

4. Without Pantrix

The same solution, with Compose Multiplatform's own API only. The code below can be copied into any project; it is a simplified form of its counterparts in the library.

4.1. Common code (commonMain)

/** A font: the file in the resources, and the weight and style the family registers it at. */
data class AppFont(
    val resource: FontResource,
    val weight: FontWeight = FontWeight.Normal,
    val style: FontStyle = FontStyle.Normal
)

/** The fonts' family. In the browser, a font that has not arrived yet is the fallback typeface in the family. */
@Composable
fun rememberFontFamily(fonts: List<AppFont>): FontFamily {
    val read = key(fonts) { fonts.map { Font(it.resource, it.weight, it.style) } }
    return remember(read) { FontFamily(read) }
}

/** Whether the fonts have all been read. Asking starts the reading; outside the web it is always true. */
@Composable
expect fun areFontsLoaded(fonts: List<AppFont>): Boolean

/** [value] itself if it has been read; if not, the last one read. If none has been read, [value] still. */
@Composable
fun <T> lastLoaded(value: T, loaded: Boolean): T {
    var last by remember { mutableStateOf<T?>(null) }
    if (!loaded) return last ?: value
    SideEffect { last = value }
    return value
}

/** Draws [content] after the typeface has been read; waits at most [timeoutMillis]. */
@Composable
fun FontGate(
    loaded: Boolean,
    timeoutMillis: Long = 3000,
    onShown: () -> Unit = {},
    content: @Composable () -> Unit
) {
    var waiting by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(timeoutMillis)
        waiting = false
    }
    // A gate once opened stays open: an app that has been drawn is not set up again.
    var opened by remember { mutableStateOf(false) }
    if (opened || loaded || !waiting) {
        SideEffect { opened = true }
        content()
        val shown by rememberUpdatedState(onShown)
        LaunchedEffect(Unit) {
            // The first frame after the frame that carries the content: that frame has been drawn.
            withFrameNanos { }
            shown()
        }
    }
}

4.2. Platform counterparts

// webMain
@OptIn(ExperimentalResourceApi::class)
@Composable
actual fun areFontsLoaded(fonts: List<AppFont>): Boolean {
    // All are asked for first, then looked at: the fonts are read side by side, not one after another.
    val read = key(fonts) { fonts.map { preloadFont(it.resource, it.weight, it.style).value } }
    return read.none { it == null }
}
// androidMain, iosMain, jvmMain
@Composable
actual fun areFontsLoaded(fonts: List<AppFont>): Boolean = true

preloadFont exists only in the web source sets and carries @ExperimentalResourceApi; it cannot be called from common code. That is why expect/actual is needed. webMain is the source set Kotlin sets up by itself when the js and wasmJs targets are both defined.

4.3. Use

// commonMain
val Brand = listOf(
    AppFont(Res.font.brand_regular),
    AppFont(Res.font.brand_bold, FontWeight.Bold)
)

@Composable
fun App() {
    val family = rememberFontFamily(Brand)
    // For a typeface that changes while the app runs: it stays with the old one until the new one has been read.
    val shown = lastLoaded(family, loaded = areFontsLoaded(Brand))
    // typographyIn: your own function that sets up your typography in the family given.
    MaterialTheme(typography = typographyIn(shown)) { /* … */ }
}
// webMain
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    val box = "app".takeIf { document.getElementById(it) != null }
    ComposeViewport(viewportContainerId = box) {
        FontGate(
            loaded = areFontsLoaded(Brand),
            onShown = { document.getElementById("loading")?.remove() }
        ) {
            App()
        }
    }
}

The page and the style are the same as those in 3.3.

5. Pitfalls

PitfallWhat happensWhat is done
The same keyIf the font read ahead and the font the theme asks for are not the same file with the same weight and the same style, it is not found in the cache; the app is drawn with the fallback againCount the fonts in one list; set up both the family and the question from that list
Asking with &&areLoaded(a) && areLoaded(b): the second is not asked at all until the first arrives; the files come down one after the otherGive them all in one call
A preload without crossoriginThe browser does not count what it downloaded as the app's fetch request; the file comes down twice and a warning falls into the consoleas="fetch" and crossorigin together
An app set up in the bodyComposeViewport clears the inside of the box it is set up in: the loading mark goes before the app's first frame, and an empty page is left betweenSet up the app in its own box; remove the mark after the first frame
A gate with no timeoutIf the file does not arrive (a wrong address, a broken network), the page stays empty for everWait a while, then draw with the fallback
A gate that can closeIf the answer turns back to "not read" later, the app is removed and loses all its stateLet a gate once opened stay open
An old index.htmlIf the browser kept the old page, there is no box; an app that looks for the box never opensIf there is no box, set up in the body
Addresses written by handWhen the module's or the file's name changes, the preload downloads for nothing; nothing breaks, the file only comes down lateA test that compares the addresses with the font folders (ours is WebFontPreloadTest)
A hidden tabThe browser gives no frames to a hidden tab; the app is not set up until the tab is visibleIt is not a bug; while measuring, see that the tab is visible
Browser testsKarma does not serve the resources: in tests the font never arrives, and the question always says "not read"Give the test your own reader (LocalResourceReader)
Reading everything aheadTypefaces that will never be used come down to every visitor tooRead ahead only the first screen's typeface; leave the others to the change
An array in webMainIn the source set that js and wasmJs share, the editor cannot resolve an array's extension: flatMap on a vararg parameter gives the warning "Cannot access 'kotlin.Cloneable' which is a supertype of 'Array'". The build passes; the warning is only in the editorTurn the vararg into a list in common code; let the platform counterpart take a list (like areFontsLoaded in 4.1)

6. Verification

The measuring script

In the browser, exactly when a frame is drawn cannot be seen from outside; the order of the events can. A small script, loaded in index.html before the app's script, is added to a copy of the production bundle:

(function () {
  const log = (m) => console.log('[font-probe] ' + Math.round(performance.now()) + 'ms ' + m);
  const isFont = (s) => s.indexOf('/font/') >= 0;
  const origFetch = window.fetch;
  window.fetch = function (u) {
    const s = String((u && u.url) || u);
    if (isFont(s)) log('fetch ' + s.split('/').pop());
    return origFetch.apply(this, arguments);
  };
  // Frames that last longer than a frame: a page set up from the start shows here.
  let last = performance.now();
  const tick = (now) => {
    if (now - last > 24) log('long frame ' + Math.round(now - last) + 'ms');
    last = now;
    if (now < 6000) requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
})();

The order expected: the fonts have been read before the app's first long frame, and after that frame no new request for a font is seen. performance.getEntriesByType('resource') tells who asked for the file: if the preload works, initiatorType is "link" and the file comes down once.

Tests

TestWhat it looks at
ui/foundation, PantrixTypefaceWebTest (in the browser)A typeface has been read when all its fonts have been read; those asked for together are read together; what has been read is ready at the next first setup; the scale stays with the old typeface until the new one arrives
ui/foundation, PantrixTypefaceTestOutside the web a typeface has been read from the start; one on its way is not given, the last one read is given
ui/foundation, PantrixTypefaceGateTestThe gate opens when the typeface arrives, stays open once opened, tells after the content has been drawn on the page, and gives up waiting after a while
app/shared, WebFontPreloadTestThe files the page downloads ahead are the same as the files in the font folders; every line carries as="fetch" and crossorigin

7. Where it is in this repository

FileWhat
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/token/typography/PantrixTypeface.ktPantrixFont, PantrixTypeface, rememberPantrixFontFamily, areTypefacesLoaded, isPantrixTypefaceLoaded
ui/foundation/src/webMain/…/PantrixTypeface.web.ktThe browser's part of areTypefacesLoaded: areFontsLoaded (preloadFont). The typefaces' fonts are gathered into one list in common code; this part only takes the list
ui/foundation/src/commonMain/…/PantrixTypography.ktrememberPantrixTypography(typeface): stays with the old typeface until the new one arrives
ui/foundation/src/commonMain/…/PantrixTypefaceGate.ktThe gate
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/theme/Typefaces.ktThe sample app's own two typefaces
app/webApp/src/webMain/kotlin/com/pantrixui/cmp/demo/main.ktThe web entry: the box, the gate, the removal of the loading mark
app/webApp/src/webMain/resources/index.html, styles.cssThe preload lines, #app and #loading

8. What is left open

The items seen in this work and not touched are in docs/OPEN_ITEMS.md. The chief ones: