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..a2bb2f42 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,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { private val fontsByWeight: MutableMap> = java.util.Collections.synchronizedMap(mutableMapOf()) + // rive-android holds FontFallbackStrategy.stylePicker only in a WeakReference (#369), + // so both fields must be written together under pickerLock. + private val pickerLock = Any() + private var strongStylePicker: FontFallbackStrategy? = null + private fun resetFontCache() { try { FontFallbackStrategy.cppResetFontCache() @@ -100,7 +105,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,7 +125,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { } } } - resetFontCache() + synchronized(pickerLock) { + strongStylePicker = picker + FontFallbackStrategy.stylePicker = picker + resetFontCache() + } } } @@ -129,8 +138,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { synchronized(fontsByWeight) { fontsByWeight.clear() } - FontFallbackStrategy.stylePicker = null - resetFontCache() + synchronized(pickerLock) { + FontFallbackStrategy.stylePicker = null + strongStylePicker = null + resetFontCache() + } } } } diff --git a/example/src/reproducers/Issue369FontPickerGC.tsx b/example/src/reproducers/Issue369FontPickerGC.tsx new file mode 100644 index 00000000..abd62442 --- /dev/null +++ b/example/src/reproducers/Issue369FontPickerGC.tsx @@ -0,0 +1,168 @@ +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' | 'churning' | 'mounted'; + +let fontOps: Promise = Promise.resolve(); + +// Fallback fonts are global native state, so set/clear must not interleave across runs. +function serializeFontOp(op: () => Promise): Promise { + const next = fontOps.then(op); + fontOps = next.catch(() => {}); + return next; +} + +export default function Issue369FontPickerGC() { + const [phase, setPhase] = useState('idle'); + const [churned, setChurned] = useState(0); + const runId = useRef(0); + + const start = useCallback(async () => { + const id = ++runId.current; + const isCurrent = () => runId.current === id; + setChurned(0); + const kanit = await RiveFonts.loadFont(FONT); + if (!isCurrent()) return; + await serializeFontOp(async () => { + if (isCurrent()) await RiveFonts.setFallbackFonts({ default: [kanit] }); + }); + if (!isCurrent()) return; + setPhase('churning'); + for (let i = 1; i <= CHURN_LOADS; i++) { + await RiveFonts.loadFont(FONT); + if (!isCurrent()) return; + if (i % 10 === 0) setChurned(i); + } + setChurned(CHURN_LOADS); + setPhase('mounted'); + }, []); + + const cancel = useCallback(() => ++runId.current, []); + + const reset = useCallback(async () => { + const id = cancel(); + setPhase('idle'); + setChurned(0); + await serializeFontOp(() => RiveFonts.clearFallbackFonts()); + return id; + }, [cancel]); + + const restart = useCallback(async () => { + const id = await reset(); + if (runId.current === id) start(); + }, [reset, start]); + + useEffect(() => { + start(); + return () => { + cancel(); + serializeFontOp(() => RiveFonts.clearFallbackFonts()); + }; + }, [start, cancel]); + + 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. + + + + 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' }, +});