

Kotlin on wasm: what surprised me

The Android and desktop builds of Aardink behaved. The browser build is where the surprises were. Most of them were bugs that had been there all along, hidden by a fast JVM and a spare thread.
The regex engine is not the browser’s
I assumed Kotlin/wasm would hand regexes to the browser’s RegExp. It does not. It ships the pure-Kotlin
kotlin.text.regex engine, and that engine evaluates a lookbehind at every candidate position.
KotlinTokenizer had the rule (?<=\b(?:class|object|interface|enum)\s) to pick out declaration names. Ten find
calls on 3 KB took 4.7 seconds. Even a fixed-length (?<=\bfun\s) cost about 2.5 ms a call.
The earlier review had flagged browser lookbehind support as the risk. Wrong premise. Support was fine. Speed was the problem.
The fix was no lookbehinds anywhere. A refine pass now types declaration names after the scan. A golden test pins it
against the old rules over every .kt file in the repo, so the tokens did not change.
Timings at 64 KB in headless Chrome after the fix:
| Language | Time |
|---|---|
| Kotlin | 170 ms |
| TypeScript | 113 ms |
| TOML | 78 ms |
| CSS | 62 ms |
| Markdown | 58 ms |
| JSON | 46 ms |
| XML, HTML | 2 ms |
Version 0.6 refuses a lookbehind in a registered grammar when the grammar is parsed, so nobody can walk back into it.
A quadratic tokeniser the JVM hid
RegexTokenizer ran every rule’s find from the cursor at every token, then threw away the losers. A rule with no
match nearby rescanned to the end of the document each time.
About 105 KB of Kotlin took over a minute on the JVM. I cache each rule’s next match now. It takes 0.3 seconds and produces identical tokens.
No user found this. The tests for cooperative tokenising did.
One thread for everything
On wasm, Dispatchers.Default is the page’s event loop. Work that runs off the UI thread on Android blocks painting in
the browser.
Version 0.5 added limits. Above 64 KB, tokenising yields every 2,000 tokens. Above 2 MB, there is no highlighting or
folding at all. Android and the JVM never check these (EditorLimits).
Yielding every 2,000 tokens was not good enough, and yield was the wrong tool. In the browser, kotlinx.coroutines runs
a batch of queued tasks per browser task. A coroutine that only yields resumes inside the same batch, before any frame
is drawn. 0.6 works in slices of about 8 ms, half a 60 Hz frame, and ends each slice with delay(1). A timer lets the
browser paint first.
Tests have the same trap. runTest never yields to the browser, so one test that blocks for more than about 2 seconds
makes Karma drop the page with no test named. If that happens, look at the first test class alphabetically after the
last one that passed.
No system fonts
The canvas has no fonts. aardink-editor-web bundles JetBrains Mono Regular, about 270 KB under the SIL OFL. Skia
synthesises bold and italic.
Compose resolves resources against the page, not the module. Inside node_modules that never works. The npm package
points the font at its own copy with new URL(..., import.meta.url), which also makes Vite emit the file.
Font(Res.font...) throws inside composition when the file 404s, and that kills the editor. So the font loads with
Res.readBytes behind a fallback instead.
Size and browser support
The .wasm is about 13 MB, about 4.7 MB gzipped. preloadAardink() starts the download early.
It needs WasmGC and exception handling: Chrome and Edge 119+, Firefox 120+, Safari 18.2+.
The compiled .mjs imports @js-joda/core, which is Compose’s date and time support. A plain HTML page cannot load it
without an import map. Bit of a bugger, but it is one block of JSON.