Pantrix UI

An app that opens in every browser

Why a Compose Multiplatform web app does not open at all in some browsers, how we found out, and how we mended it. The problem is not Pantrix's: it shows the same way in every Compose Multiplatform app whose web target is Wasm alone (wasmJs).

1. The problem

What is seen

CompileError: WebAssembly.Module(): Unknown type code 0x5e, enable with --experimental-wasm-gc @+11
Please make sure that your runtime environment supports the latest version of Wasm GC and Exception-Handling proposals.

Why

BrowserFirst version
Chrome, Edge, Chrome and WebView on Android119
Firefox120
Safari, every browser on iOS (WebKit)18.2

Finding it

adb shell dumpsys package com.android.chrome | grep versionName
adb forward tcp:9333 localabstract:chrome_devtools_remote
node --no-experimental-wasm-legacy-eh -e "new WebAssembly.Module(require('fs').readFileSync('app.wasm'))"

2. The solution: two builds and a loader

PartWhat it does
A js targetThe app's same code is built with Kotlin/JS too. Skia is still Wasm, but plain Wasm with no GC: every browser runs it
Two scripts of their own namessite-wasm.js and site-js.js; both go in the same folder
A loader (site.js)Tests the browser with Compose's own module and chooses the build; where the Wasm build fails with a Wasm error before it has drawn its first frame, it starts the JS build; where that fails too, it shows the static text
A mark on the page<html class="app"> in a browser that has Wasm: the static text is hidden, the app's box is shown

The moment the app draws its first frame is the moment it takes the loading mark away: from it, the loader tells an error of the app's opening from one of some work after it.

3. Gradle

3.1. The targets

The app's web module is built for two targets, with two script names. Every multiplatform module the app uses gets the js target too.

kotlin {
    js {
        browser {
            commonWebpackConfig { outputFileName = "site-js.js" }
        }
        binaries.executable()
    }
    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        browser {
            commonWebpackConfig { outputFileName = "site-wasm.js" }
        }
        binaries.executable()
    }
}

Where the common tests of the app's modules already run in a browser on Wasm, the browser tests on JS can be turned off (js { browser { testTask { enabled = false } } }): the JS build is the same code. That the tests compile for JS is still checked (compileTestKotlinJs).

3.2. One folder

The JS distribution's script and Skia's Wasm, named by its hash, are copied into the Wasm distribution's folder; the page, the styles and the resources are in the Wasm distribution already. Skia's Wasm is the same file in both builds, has the same name and is downloaded once.

val copyJs = tasks.register<Copy>("copyJsApp") {
    mustRunAfter("jsBrowserDistribution")
    from(layout.buildDirectory.dir("dist/js/productionExecutable")) {
        include("site-js.js", "*.wasm")
        exclude("skiko.wasm")
    }
    into(layout.buildDirectory.dir("dist/wasmJs/productionExecutable"))
}
tasks.named("wasmJsBrowserDistribution") {
    dependsOn("jsBrowserDistribution")
    finalizedBy(copyJs)
}

4. The code

} catch (cancelled: CancellationException) {
    throw cancelled
} catch (failure: JsException) {
    null
}

5. The loader and the page

5.1. The page

<head>
    <!-- A browser with Wasm runs the app: it hides the static text. -->
    <script>
        if (typeof WebAssembly === "object") document.documentElement.className = "app";
    </script>
</head>
<body>
    <div id="loading">…</div>
    <div id="app"></div>
    <script type="application/javascript" src="site.js"></script>
</body>
html.app #static { display: none; }
html:not(.app) #loading,
html:not(.app) #app { display: none; }

The app takes #loading away once it has drawn its first frame (Compose's ComposeViewport is set in #app; the mark stands over it).

5.2. site.js

(function () {
    "use strict";
    // The module of Compose's loader: a struct type and a `try`. A browser that takes it has both GC and exceptions.
    var FEATURES = new Uint8Array([
        0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 2, 95, 1, 120, 0, 96, 0, 0, 3, 3, 2, 1, 1, 10, 14, 2, 6, 0, 6, 64, 25, 11,
        11, 5, 0, 208, 112, 26, 11, 0, 45, 4, 110, 97, 109, 101, 1, 15, 2, 0, 5, 102, 117, 110, 99, 48, 1, 5, 102, 117,
        110, 99, 49, 4, 8, 1, 0, 5, 116, 121, 112, 101, 48, 10, 11, 1, 0, 1, 0, 6, 102, 105, 101, 108, 100, 48
    ]);
    var WASM = "site-wasm.js", JS = "site-js.js";
    var starting = null;

    function drawn() { return !document.getElementById("loading"); }
    function load(src, onFailure) {
        var script = document.createElement("script");
        script.src = src;
        script.onerror = onFailure;
        document.body.appendChild(script);
    }
    function showStaticWords() {
        starting = null;
        document.documentElement.classList.remove("app");
    }
    function startJavaScript() {
        starting = "js";
        var box = document.getElementById("app");
        while (box && box.firstChild) box.removeChild(box.firstChild);
        load(JS, showStaticWords);
    }
    function wasmFailed(error) {
        var wasm = typeof WebAssembly === "object";
        return error instanceof RangeError || wasm && (error instanceof WebAssembly.CompileError ||
            error instanceof WebAssembly.LinkError || error instanceof WebAssembly.RuntimeError);
    }
    function thrownBy(error, script) { return String(error && error.stack || "").indexOf(script) >= 0; }
    function failed(error) {
        if (starting === null || drawn() || !wasmFailed(error)) return;
        if (starting === "wasm") startJavaScript();
        else if (!thrownBy(error, WASM)) showStaticWords();
    }

    window.addEventListener("error", function (event) { failed(event.error); });
    window.addEventListener("unhandledrejection", function (event) { failed(event.reason); });

    var collected = false;
    try { collected = typeof WebAssembly === "object" && WebAssembly.validate(FEATURES); } catch (e) {}
    if (collected) { starting = "wasm"; load(WASM, startJavaScript); }
    else if (typeof WebAssembly === "object") startJavaScript();
    else showStaticWords();
})();

6. The server

map $uri $site_cache {
    "~^/[0-9a-f]{16,}\.wasm$" "public, max-age=31536000, immutable";
    default                   "no-cache";
}

7. Pitfalls

PitfallWhat happensWhat to do
The tab behindWhere the tab DevTools drives is not the one in front, the browser stops drawing frames: the app draws no first frame, the loading mark stays, and no error is writtenThe tab tried is brought to the front (/json/activate/<id>)
The emulator's ChromeThe system image's Chrome is old and stays so, not updatedIt is of use for just that reason: it tries the old browser's way
A finalizer in Gradle"Unable to make progress running work"The JS distribution is a dependency of the Wasm one, the copy only a finalizer (3.2)
JsExceptionCatches the cancellation too in JSThe cancellation is sent up first (section 4)
Webpack and import.meta.urlThe import.meta.url in Skia's JavaScript is written as the file's path on the machine that builds the script (file:///Users/…): a user name and folders in a public fileThe script reads it only for the folder it was loaded from, and does not use it here: a webpack plugin turns every "file:///…" string into an empty one, after the script is minified and before its map is written (webpack.config.d/stripFilePaths.js, section 9)
adb screencapOn an emulator with more than one screen (a foldable), it warns and takes the firstThe screen is chosen with -d <screen>

8. Verification

What was seen (2026-10-31, Pantrix UI's site, on the local stack: the emulator reached it with adb reverse, the simulator with localhost):

WhereBrowserResult
Android emulator, Android 14Chrome 113 (no Wasm GC)The loader chose site-js.js; the home page and Installation were drawn by the JS build
Desktop, headless ChromeHas Wasm GCOnly site-wasm.js was loaded, and drew
Desktop, headless Chrome, the app's Wasm answered with broken bytesHas Wasm GCCompileError → the JS build started and drew
Desktop, headless Chrome, both Wasm files brokenHas Wasm GCNeither build could open → the static text showed
This repository's demo: the same three ways in the emulator's Chrome 113 and in headless ChromeAllThe Button page was drawn by the JS build; with a broken Wasm it went over to JS; with both Wasm files broken the note "this browser cannot run the demo" stayed
iOS simulator, iOS 27.0SafariThe Wasm build drew
Both ways, in report-only modeChromeNo violation of the content security policy

What was not seen:

9. Where

Pantrix UI's site (the pantrix-ui-site repository):

FileWhat
build-logic/.../WebApplicationConventionPlugin.ktThe two scripts, the JS distribution copied into the Wasm folder
build-logic/.../KmpLibraryConventionPlugin.ktThe js target in every module, its browser tests off
app/webApp/src/webMain/resources/site.jsThe loader
app/webApp/src/webMain/resources/index.htmlThe app mark and the script that calls the loader
app/webApp/src/{webMain,wasmJsMain,jsMain}/kotlin/.../WasmMemory*.ktCode only Wasm has, as expect/actual
deploy/check.shThe live check of the loader, the two builds and the Wasm files
app/webApp/webpack.config.d/stripFilePaths.jsThe webpack plugin that takes the building machine's path out of the scripts

This repository's web demo (app/webApp) has the same solution: webApp-wasm.js and webApp-js.js, the loader being the webApp.js its page loads already. As it has no static text, a note saying so stays in the loading mark's place where neither build can open. The webpack plugin is the same there.

FileWhat
app/webApp/build.gradle.ktsThe two scripts, the JS distribution copied into the Wasm folder (copyJsApp)
app/webApp/src/webMain/resources/webApp.jsThe loader
app/webApp/webpack.config.d/stripFilePaths.jsTakes the building machine's path out of the scripts