Post hero imagePost hero image

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:

LanguageTime
Kotlin170 ms
TypeScript113 ms
TOML78 ms
CSS62 ms
Markdown58 ms
JSON46 ms
XML, HTML2 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.