From f6e082edb5f2443bb063bd6c77b5863eb2237ceb 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/5] 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 cff1c48b7fac6fd36560631df81818b2452705e1 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/5] 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' }, +}); From 87cd7b8aecc678b55e2305feb02b62ed6aed9284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 28 Oct 2026 11:26:41 +0100 Subject: [PATCH 3/5] fix(android): publish the fallback font picker atomically Concurrent applyFallbackFonts/clearFallbackFonts calls run on Dispatchers.Default and could interleave the strong-ref and stylePicker writes, leaving rive-android weakly holding a picker nobody else retains. --- .../nitro/rive/HybridRiveFontConfig.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 818b9d02..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,8 +22,9 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { private val fontsByWeight: MutableMap> = java.util.Collections.synchronizedMap(mutableMapOf()) - // rive-android holds FontFallbackStrategy.stylePicker only in a WeakReference (#369). - @Volatile + // 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() { @@ -124,9 +125,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { } } } - strongStylePicker = picker - FontFallbackStrategy.stylePicker = picker - resetFontCache() + synchronized(pickerLock) { + strongStylePicker = picker + FontFallbackStrategy.stylePicker = picker + resetFontCache() + } } } @@ -135,9 +138,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() { synchronized(fontsByWeight) { fontsByWeight.clear() } - FontFallbackStrategy.stylePicker = null - strongStylePicker = null - resetFontCache() + synchronized(pickerLock) { + FontFallbackStrategy.stylePicker = null + strongStylePicker = null + resetFontCache() + } } } } From fcbecfd153f9ac0194e9d0b4f2825baf095648bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:37:51 +0100 Subject: [PATCH 4/5] fix(example): cancel superseded runs in the #369 reproducer --- .../src/reproducers/Issue369FontPickerGC.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/example/src/reproducers/Issue369FontPickerGC.tsx b/example/src/reproducers/Issue369FontPickerGC.tsx index 69c88145..34115b19 100644 --- a/example/src/reproducers/Issue369FontPickerGC.tsx +++ b/example/src/reproducers/Issue369FontPickerGC.tsx @@ -31,41 +31,57 @@ const FONT = 'kanit_regular.ttf'; const THAI = 'สวัสดี โลก'; const CHURN_LOADS = 250; -type Phase = 'idle' | 'fallback set' | 'churning' | 'mounted'; +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 running = useRef(false); + const runId = useRef(0); const start = useCallback(async () => { - if (running.current) return; - running.current = true; + const id = ++runId.current; + const isCurrent = () => runId.current === id; setChurned(0); const kanit = await RiveFonts.loadFont(FONT); - await RiveFonts.setFallbackFonts({ default: [kanit] }); + 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 && running.current; i++) { + for (let i = 1; i <= CHURN_LOADS; i++) { await RiveFonts.loadFont(FONT); + if (!isCurrent()) return; if (i % 10 === 0) setChurned(i); } setChurned(CHURN_LOADS); - if (running.current) setPhase('mounted'); + setPhase('mounted'); + }, []); + + const cancel = useCallback(() => { + runId.current++; }, []); const reset = useCallback(async () => { - running.current = false; + cancel(); setPhase('idle'); setChurned(0); - await RiveFonts.clearFallbackFonts(); - }, []); + await serializeFontOp(() => RiveFonts.clearFallbackFonts()); + }, [cancel]); useEffect(() => { start(); - return () => { - running.current = false; - }; - }, [start]); + return cancel; + }, [start, cancel]); return ( From 2380d99a7aba5969785d9e3071379a2f7a9e3cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:45:28 +0100 Subject: [PATCH 5/5] fix(example): clear fallback fonts when leaving the #369 reproducer --- .../src/reproducers/Issue369FontPickerGC.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/example/src/reproducers/Issue369FontPickerGC.tsx b/example/src/reproducers/Issue369FontPickerGC.tsx index 34115b19..abd62442 100644 --- a/example/src/reproducers/Issue369FontPickerGC.tsx +++ b/example/src/reproducers/Issue369FontPickerGC.tsx @@ -67,20 +67,27 @@ export default function Issue369FontPickerGC() { setPhase('mounted'); }, []); - const cancel = useCallback(() => { - runId.current++; - }, []); + const cancel = useCallback(() => ++runId.current, []); const reset = useCallback(async () => { - cancel(); + 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; + return () => { + cancel(); + serializeFontOp(() => RiveFonts.clearFallbackFonts()); + }; }, [start, cancel]); return ( @@ -96,13 +103,7 @@ export default function Issue369FontPickerGC() { fallback picker was garbage-collected. - { - await reset(); - start(); - }} - > + Start