Pantrix UI

Her tarayıcıda açılan uygulama

Compose Multiplatform'un web uygulamasının bazı tarayıcılarda neden hiç açılmadığı, bunu nasıl teşhis ettiğimizi ve nasıl giderdiğimizi. Sorun Pantrix'e özgü değildir: web hedefi yalnızca Wasm (wasmJs) olan her Compose Multiplatform uygulamasında aynı şekilde görülür.

1. Sorun

Belirti

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.

Neden

Tarayıcıİlk sürüm
Chrome, Edge, Android'deki Chrome ve WebView119
Firefox120
Safari, iOS'taki bütün tarayıcılar (WebKit)18.2

Teşhis

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. Çözüm: iki sürüm ve bir yükleyici

ParçaNe yapar
js hedefiUygulamanın aynı kodu bir de Kotlin/JS ile derlenir. Skia yine Wasm'dır, ama GC'siz düz Wasm: her tarayıcı çalıştırır
Ayrı adlı iki paketsite-wasm.js ve site-js.js; ikisi aynı klasöre konur
Yükleyici (site.js)Tarayıcıyı Compose'un kendi modülüyle sınar ve sürümü seçer; Wasm sürümü ilk karesini çizmeden bir Wasm hatasıyla çökerse JS sürümünü başlatır; o da çökerse statik metni gösterir
Sayfadaki işaretWasm'ı olan bir tarayıcıda <html class="app">: statik metin gizlenir, uygulamanın kutusu görünür

Uygulamanın ilk karesini çizdiği an, yükleme işaretini kaldırdığı andır: yükleyici bir hatanın uygulamanın açılışına mı, açıldıktan sonraki bir işe mi ait olduğunu bundan anlar.

3. Gradle

3.1. Hedefler

Uygulamanın web modülü iki hedefle, iki ayrı paket adıyla derlenir. Uygulamanın kullandığı her çoklu platform modülüne de js hedefi eklenir.

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

Uygulamanın modüllerinde ortak testler zaten Wasm'da tarayıcıda koşuyorsa, JS'teki tarayıcı testleri kapatılabilir (js { browser { testTask { enabled = false } } }): JS sürümü aynı koddur. Testlerin JS'te derlendiği yine denetlenir (compileTestKotlinJs).

3.2. Tek klasör

JS dağıtımının paketi ve Skia'nın hash adlı Wasm'ı, Wasm dağıtımının klasörüne kopyalanır; sayfa, stiller ve kaynaklar Wasm dağıtımında zaten vardır. Skia'nın Wasm'ı iki sürümde aynı dosyadır, aynı adı taşır ve bir kez indirilir.

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. Kod

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

5. Yükleyici ve sayfa

5.1. Sayfa

<head>
    <!-- Wasm'ı olan bir tarayıcı uygulamayı çalıştırır: statik metni gizler. -->
    <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; }

Uygulama ilk karesini çizince #loading'i kaldırır (Compose'un ComposeViewport'u #app'e kurulur; işaret onun üstünde durur).

5.2. site.js

(function () {
    "use strict";
    // Compose'un yükleyicisinin modülü: bir struct türü ve bir `try`. Bunu kabul eden tarayıcıda GC de istisnalar da var.
    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. Sunucu

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

7. Tuzaklar

TuzakNe olurNe yapılır
Arka plandaki sekmeDevTools ile sürülen sekme öndeki değilse tarayıcı kare çizmeyi durdurur: uygulama ilk karesini çizmez, yükleme işareti kalkmaz, hata da yazılmazSınanan sekme öne alınır (/json/activate/<id>)
Emülatörün Chrome'uSistem imajının Chrome'u eskidir ve güncellenmeden kalırEski tarayıcı yolunu sınamak için tam da bu yüzden işe yarar
Gradle'da sonlandırıcı"Unable to make progress running work"JS dağıtımı Wasm dağıtımının bağımlılığı olur, kopya yalnızca sonlandırıcı (3.2)
JsExceptionJS'te iptali de yakalarİptal önce yukarı gönderilir (4. bölüm)
Webpack ve import.meta.urlSkia'nın JavaScript'indeki import.meta.url, pakete derleyen makinedeki dosya yolu olarak yazılır (file:///Users/…): herkese açık bir dosyada kullanıcı adı ve klasörlerPaket yalnızca yüklendiği klasörü bulmak için okur ve burada kullanmaz: bir webpack eklentisi, paket küçültüldükten sonra ve harita yazılmadan önce, her "file:///…" dizgisini boş dizgi yapar (webpack.config.d/stripFilePaths.js, 9. bölüm)
adb screencapBirden çok ekranı olan bir emülatörde (katlanabilir) uyarı verir ve ilk ekranı alır-d <ekran> ile ekran seçilir

8. Doğrulama

Görülenler (2026-10-31, Pantrix UI'ın sitesi, yerel yığında: emülatör ona adb reverse ile, simülatör localhost ile bağlandı):

OrtamTarayıcıSonuç
Android emülatörü, Android 14Chrome 113 (Wasm GC yok)Yükleyici site-js.js'i seçti; ana sayfa ve Kurulum JS sürümüyle çizildi
Masaüstü, başsız ChromeWasm GC varYalnızca site-wasm.js yüklendi ve çizildi
Masaüstü, başsız Chrome, uygulamanın Wasm'ı bozuk baytlarla yanıtlanıncaWasm GC varCompileError → JS sürümü başladı ve çizildi
Masaüstü, başsız Chrome, iki Wasm da bozukWasm GC varİki sürüm de açılamadı → statik metin göründü
Bu deponun demosu: emülatör, Chrome 113 ve başsız Chrome'da aynı üç yolHepsiButton sayfası JS sürümüyle çizildi; bozuk Wasm'da JS'e geçti; iki Wasm da bozukken "bu tarayıcı demoyu çalıştıramıyor" yazısı kaldı
iOS simülatörü, iOS 27.0SafariWasm sürümü çizildi
İki yol da, rapor modundaChromeİçerik güvenliği politikasına hiçbir ihlal yok

Görülmeyenler:

9. Nerede

Pantrix UI'ın sitesi (pantrix-ui-site deposu):

DosyaNe
build-logic/.../WebApplicationConventionPlugin.ktİki paket, JS dağıtımının Wasm klasörüne kopyası
build-logic/.../KmpLibraryConventionPlugin.ktHer modülde js hedefi, tarayıcı testleri kapalı
app/webApp/src/webMain/resources/site.jsYükleyici
app/webApp/src/webMain/resources/index.htmlapp işareti ve yükleyiciyi çağıran betik
app/webApp/src/{webMain,wasmJsMain,jsMain}/kotlin/.../WasmMemory*.ktYalnızca Wasm'da olan kod, expect/actual
deploy/check.shYükleyicinin, iki sürümün ve Wasm dosyalarının canlıdaki denetimi
app/webApp/webpack.config.d/stripFilePaths.jsPaketlerden derleyen makinenin yolunu çıkaran webpack eklentisi

Bu deponun web demosu (app/webApp) aynı çözümle: webApp-wasm.js ve webApp-js.js, yükleyici sayfanın zaten yüklediği webApp.js'tir. Statik metni olmadığından, iki sürüm de açılamazsa yükleme işaretinin yerine bunu söyleyen bir yazı kalır. Webpack eklentisi de orada aynıdır.

DosyaNe
app/webApp/build.gradle.ktsİki paket, JS dağıtımının Wasm klasörüne kopyası (copyJsApp)
app/webApp/src/webMain/resources/webApp.jsYükleyici
app/webApp/webpack.config.d/stripFilePaths.jsDerleyen makinenin yolunu paketlerden çıkarır