On the WASM/web build, emojis rendered by EmojiText (most visibly
ClientScreen’s top-right view-mode/edit-mode/theme-mode toggle buttons)
randomly showed as tofu boxes instead of the intended glyph, and a full
page reload usually — but not always — fixed it.
EmojiSupport.emojiFontFamily (the loaded NotoColorEmoji font family used
to style emoji spans on WASM) was a plain var, not Compose snapshot
state. EmojiSupport.PreloadEmojiFont() fetches the font asynchronously
(Res.readBytes(...) over the network) and assigns it once the fetch
resolves. EmojiText reads EmojiSupport.emojiFontFamily at composition
time and only applies the emoji font to spans if that value is non-null.
Because the field wasn’t observable, any EmojiText that composed before
the async fetch completed captured null and built its AnnotatedString
with no emoji-font span. When the fetch later resolved and the var was
reassigned, nothing told Compose to invalidate and recompose those already-
composed EmojiText instances — they were stuck showing tofu until
something else forced a fresh composition. A page refresh often “fixed” it
purely because the browser’s HTTP cache made the second fetch resolve
before the first composition pass, which is also why the bug looked
random rather than consistent.
composeApp/src/commonMain/kotlin/krill/zone/app/EmojiSupport.kt:
changed emojiFontFamily from a plain var to by mutableStateOf(null)
so any composable reading it (in particular EmojiText) is subscribed to
the snapshot system and recomposes automatically once the async font load
lands, regardless of composition order.EmojiSupport.setEmojiFontFamilyForTest / resetForTest — internal
test-only seams, since desktop’s loadEmojiFontFamily() always returns
null (desktop has native emoji support) and can’t exercise the late-load
race directly.composeApp/src/desktopTest/kotlin/krill/zone/app/EmojiSupportReactivityTest.kt
composes a probe that records EmojiSupport.emojiFontFamily and a
composition counter, then mutates the font family after the first
composition (simulating the async fetch landing late) and asserts a
recomposition happens and the observed value updates — this fails against
the old plain-var field and passes with the mutableStateOf fix.@Composable reads
and that can change after first composition from an async source (network
fetch, IO callback, timer) must be backed by Compose state
(mutableStateOf, StateFlow.collectAsState(), etc.), never a plain
var. A plain var only “happens to work” when the value settles before
the first read, which is not a guarantee — it degrades into a flaky,
hard-to-reproduce bug exactly like this one.