Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() {
private val fontsByWeight: MutableMap<Int, List<HybridFallbackFontSpec>> =
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()
Expand Down Expand Up @@ -100,7 +105,7 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() {

override fun applyFallbackFonts(): Promise<Unit> {
return Promise.async {
FontFallbackStrategy.stylePicker = object : FontFallbackStrategy {
val picker = object : FontFallbackStrategy {
override fun getFont(weight: Fonts.Weight): List<ByteArray> {
val requestedWeight = weight.weight
val specs = synchronized(fontsByWeight) {
Expand All @@ -120,7 +125,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() {
}
}
}
resetFontCache()
synchronized(pickerLock) {
strongStylePicker = picker
FontFallbackStrategy.stylePicker = picker
resetFontCache()
}
}
}

Expand All @@ -129,8 +138,11 @@ class HybridRiveFontConfig : HybridRiveFontConfigSpec() {
synchronized(fontsByWeight) {
fontsByWeight.clear()
}
FontFallbackStrategy.stylePicker = null
resetFontCache()
synchronized(pickerLock) {
FontFallbackStrategy.stylePicker = null
strongStylePicker = null
resetFontCache()
}
}
}
}
168 changes: 168 additions & 0 deletions example/src/reproducers/Issue369FontPickerGC.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown> = Promise.resolve();

// Fallback fonts are global native state, so set/clear must not interleave across runs.
function serializeFontOp<T>(op: () => Promise<T>): Promise<T> {
const next = fontOps.then(op);
fontOps = next.catch(() => {});
return next;
}

export default function Issue369FontPickerGC() {
const [phase, setPhase] = useState<Phase>('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 (
<View style={styles.container}>
<View style={styles.riveContainer}>
{phase === 'mounted' ? <ThaiText /> : null}
</View>
<Text style={styles.status}>
phase: {phase} · churned {churned}/{CHURN_LOADS}
</Text>
<Text style={styles.hint}>
Expected: "{THAI}" renders in the Rive view above. Blank means the
fallback picker was garbage-collected.
</Text>
<View style={styles.buttons}>
<Pressable style={styles.button} onPress={restart}>
<Text style={styles.buttonText}>Start</Text>
</Pressable>
<Pressable style={styles.button} onPress={reset}>
<Text style={styles.buttonText}>Reset</Text>
</Pressable>
</View>
</View>
);
}

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 <ActivityIndicator size="large" />;
}
return (
<RiveView
hybridRef={setHybridRef}
file={riveFile}
dataBind={instance}
fit={Fit.Contain}
style={StyleSheet.absoluteFill}
/>
);
}

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' },
});
Loading