Skip to content

[Android] RiveFonts: loadFont({name}) fails for script fonts on OEM fonts.xml; systemFallback() is not script-aware (tofu for Thai/Hebrew/Arabic/CJK) #369

Description

@edgartoomikw

UPDATE (root cause found): the primary bug is that the fallback strategy is garbage-collected. See "Root cause" below; the original report about name lookup is kept as a secondary issue.

Root cause: stylePicker is assigned into a WeakReference and immediately GC'd

rive-android stores the fallback strategy weakly (FontFallbackStrategy.kt):

// Use a WeakReference so these can be automatically cleaned up by the JVM.
private var stylePickerRef: WeakReference<FontFallbackStrategy>? = null

…but HybridRiveFontConfig.applyFallbackFonts() (both 0.4.10 and 0.4.19) assigns an anonymous object without retaining any strong reference to it:

override fun applyFallbackFonts(): Promise<Unit> {
  return Promise.async {
    FontFallbackStrategy.stylePicker = object : FontFallbackStrategy { ... } // ← nothing holds this
    ...
  }
}

The strategy becomes unreachable as soon as applyFallbackFonts returns, the next GC collects it, pickFont() starts returning emptyList(), and the engine falls back to the default system font — tofu for any non-Latin script. This explains why setFallbackFonts appears to succeed (JS promise resolves, fonts are loaded into byte arrays) while rendering still shows tofu, and why results look flaky (depends on GC timing).

Verified fix (we run this as a local patch)

Keep a strong reference alongside the weak assignment:

companion object {
  private var strongStylePicker: FontFallbackStrategy? = null
}

override fun applyFallbackFonts(): Promise<Unit> {
  return Promise.async {
    val picker = object : FontFallbackStrategy { ... }
    strongStylePicker = picker            // retain: rive-android holds only a WeakReference
    FontFallbackStrategy.stylePicker = picker
    resetFontCache()
  }
}

override fun clearFallbackFonts(): Promise<Unit> {
  return Promise.async {
    ...
    strongStylePicker = null
    FontFallbackStrategy.stylePicker = null
    resetFontCache()
  }
}

With this one-line retention, setFallbackFonts({ default: [loadedBytesFonts..., systemFallback()] }) works as documented on @rive-app/react-native 0.4.10 (rive-android 11.4.0): Thai/Hebrew/Arabic render correctly. Happy to send this as a PR.

App-side note for others hitting this

Because pickFont results are cached natively per weight, apply the new fonts before any text run is (re)shaped for the new locale — otherwise the tofu shaping result is cached until the strategy instance changes again.


Secondary issue (original report): font sourcing

1. loadFont({ name }) silently fails for script fonts on many devicesloadFontByName matches the name attribute of <family> entries in fonts.xml, but script families (Thai, Hebrew, Arabic, CJK, …) are typically declared without a name attribute (matched by lang). A lang-based lookup, or documented support for absolute font paths, would make sourcing reliable. Workaround that works: loadFont({ uri: 'file:///system/fonts/NotoSansThai-Regular.ttf' }) (routed to loadFontFromURL, which handles file://).

2. systemFallback() is not script-awareHybridDefaultFallbackFont resolves to FontHelper.getFallbackFontBytes(FontOpts(weight)), i.e. the default family (Roboto), which cannot cover non-Latin scripts. Per-script resolution (like iOS does natively) would make systemFallback() a real safety net.

Environment

  • @rive-app/react-native 0.4.10 (rive-android 11.4.0) and 0.4.19 — same code in HybridRiveFontConfig.applyFallbackFonts
  • React Native 0.85/0.86, new arch, Hermes, Android

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions