From 3b3d24a89be3531445f55c7dd22236d70ffb80a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 27 Sep 2026 07:49:03 +0200 Subject: [PATCH 1/2] fix(android): keep a strong reference to the fallback font picker rive-android stores FontFallbackStrategy.stylePicker in a WeakReference, so the anonymous picker was collected at the next GC and fallback fonts silently stopped working (#369). --- .../java/com/margelo/nitro/rive/HybridRiveFontConfig.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFontConfig.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFontConfig.kt index 8245a31d..818b9d02 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFontConfig.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFontConfig.kt @@ -22,6 +22,10 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { private val fontsByWeight: MutableMap> = java.util.Collections.synchronizedMap(mutableMapOf()) + // rive-android holds FontFallbackStrategy.stylePicker only in a WeakReference (#369). + @Volatile + private var strongStylePicker: FontFallbackStrategy? = null + private fun resetFontCache() { try { FontFallbackStrategy.cppResetFontCache() @@ -100,7 +104,7 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { override fun applyFallbackFonts(): Promise { return Promise.async { - FontFallbackStrategy.stylePicker = object : FontFallbackStrategy { + val picker = object : FontFallbackStrategy { override fun getFont(weight: Fonts.Weight): List { val requestedWeight = weight.weight val specs = synchronized(fontsByWeight) { @@ -120,6 +124,8 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { } } } + strongStylePicker = picker + FontFallbackStrategy.stylePicker = picker resetFontCache() } } @@ -130,6 +136,7 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { fontsByWeight.clear() } FontFallbackStrategy.stylePicker = null + strongStylePicker = null resetFontCache() } } From 4eb92044b5c6e8b1a2a8b6fcaed775047414b425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sat, 3 Oct 2026 08:41:36 +0200 Subject: [PATCH 2/2] test: add a reproducer page for the fallback font picker GC (#369) --- .../src/reproducers/Issue369FontPickerGC.tsx | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 example/src/reproducers/Issue369FontPickerGC.tsx diff --git a/example/src/reproducers/Issue369FontPickerGC.tsx b/example/src/reproducers/Issue369FontPickerGC.tsx new file mode 100644 index 00000000..69c88145 --- /dev/null +++ b/example/src/reproducers/Issue369FontPickerGC.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + ActivityIndicator, +} from 'react-native'; +import { + Fit, + RiveView, + RiveFonts, + useRive, + useRiveFile, + useRiveString, + useViewModelInstance, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Issue #369: on Android, rive-android keeps the fallback-font picker only in a + * WeakReference. If a GC runs between setFallbackFonts() and the first text + * that needs a fallback, the picker is gone and non-Latin text renders blank. + * + * Start sets Kanit (Thai) as the fallback font, loads it again CHURN_LOADS + * times to make the Java heap collect, then mounts a view showing Thai text. + * Expected: the Thai text renders in Kanit. + */ + +const FONT = 'kanit_regular.ttf'; +const THAI = 'สวัสดี โลก'; +const CHURN_LOADS = 250; + +type Phase = 'idle' | 'fallback set' | 'churning' | 'mounted'; + +export default function Issue369FontPickerGC() { + const [phase, setPhase] = useState('idle'); + const [churned, setChurned] = useState(0); + const running = useRef(false); + + const start = useCallback(async () => { + if (running.current) return; + running.current = true; + setChurned(0); + const kanit = await RiveFonts.loadFont(FONT); + await RiveFonts.setFallbackFonts({ default: [kanit] }); + setPhase('churning'); + for (let i = 1; i <= CHURN_LOADS && running.current; i++) { + await RiveFonts.loadFont(FONT); + if (i % 10 === 0) setChurned(i); + } + setChurned(CHURN_LOADS); + if (running.current) setPhase('mounted'); + }, []); + + const reset = useCallback(async () => { + running.current = false; + setPhase('idle'); + setChurned(0); + await RiveFonts.clearFallbackFonts(); + }, []); + + useEffect(() => { + start(); + return () => { + running.current = false; + }; + }, [start]); + + return ( + + + {phase === 'mounted' ? : null} + + + phase: {phase} · churned {churned}/{CHURN_LOADS} + + + Expected: "{THAI}" renders in the Rive view above. Blank means the + fallback picker was garbage-collected. + + + { + await reset(); + start(); + }} + > + Start + + + Reset + + + + ); +} + +function ThaiText() { + const { riveViewRef, setHybridRef } = useRive(); + const { riveFile } = useRiveFile( + require('../../assets/rive/font_fallback.riv') + ); + const { instance } = useViewModelInstance(riveFile, { async: true }); + const { setValue } = useRiveString('text', instance); + + useEffect(() => { + setValue(THAI); + riveViewRef?.playIfNeeded(); + }, [setValue, riveViewRef]); + + if (!riveFile || !instance) { + return ; + } + return ( + + ); +} + +Issue369FontPickerGC.metadata = { + name: 'Issue #369: fallback font picker GC', + description: 'Android fallback fonts stop working after a garbage collection', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff', padding: 16 }, + riveContainer: { + height: 200, + backgroundColor: '#222', + borderRadius: 8, + justifyContent: 'center', + overflow: 'hidden', + }, + status: { marginTop: 16, fontSize: 14, fontFamily: 'monospace' }, + hint: { marginTop: 8, fontSize: 13, color: '#666' }, + buttons: { flexDirection: 'row', gap: 12, marginTop: 16 }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + }, + buttonText: { color: '#fff', fontWeight: '600' }, +});