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
119 changes: 119 additions & 0 deletions demo/react-native-expo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,85 @@ const PAGES = [
{ id: "7", latex: String.raw`\text{😊} \quad E=mc^2` },
];

// ─── Streaming math: already-rendered lines jump on each appended line ──
//
// Simulates an LLM streaming a multi-line formula: one line is appended to a
// growing `\begin{aligned}` block on a timer. On Android (Fabric), the shadow
// node measures the NEW latex synchronously at commit (the box grows), but the
// view swaps its renderer asynchronously a frame later — so for one frame the
// old (shorter) content is re-centered inside the taller box and every
// already-rendered line visibly nudges down, then snaps back.
//
// Repro requirements (deliberate):
// - The RaTeXView key is STABLE across appends — a per-append key would
// remount the view and show a blank flash instead of the jump.
// - The formula is TOP-ALIGNED in its card — a centering parent would move
// the whole view on growth and mask the intra-view nudge.
const STREAM_LINES = [
String.raw`f(x) &= (x + 1)^2`,
String.raw`&= x^2 + 2x + 1`,
String.raw`\int_0^\infty e^{-x^2}\,dx &= \frac{\sqrt{\pi}}{2}`,
String.raw`\sum_{n=1}^{\infty} \frac{1}{n^2} &= \frac{\pi^2}{6}`,
String.raw`e^{i\pi} + 1 &= 0`,
String.raw`\frac{d}{dx}\left(\frac{u}{v}\right) &= \frac{u'v - uv'}{v^2}`,
String.raw`x &= \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`,
String.raw`\lim_{x \to 0} \frac{\sin x}{x} &= 1`,
];

function CaseStreamingMath() {
const [lineCount, setLineCount] = useState(1);
const [running, setRunning] = useState(true);
const [autoGrow, setAutoGrow] = useState(false);

useLayoutEffect(() => {
if (!running) return;
const id = setInterval(() => {
// Wrap around so the jump can be observed continuously.
setLineCount((n) => (n >= STREAM_LINES.length ? 1 : n + 1));
}, 700);
return () => clearInterval(id);
}, [running]);

const latex =
String.raw`\begin{aligned}` +
STREAM_LINES.slice(0, lineCount).join(String.raw` \\ `) +
String.raw`\end{aligned}`;

return (
<View style={[styles.card, styles.streamCard]}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>
Streaming math — watch rendered lines jump (Android)
</Text>
</View>
<Text style={styles.streamHint}>
A line is appended every 700 ms ({lineCount}/{STREAM_LINES.length}).
Broken: on each append the already-rendered lines nudge down for one
frame, then snap back. Fixed / iOS: lines never move.
</Text>
<Pressable
style={styles.button}
onPress={() => setRunning((r) => !r)}
>
<Text style={styles.buttonText}>{running ? "Pause" : "Resume"}</Text>
</Pressable>
<Pressable
style={styles.streamCheckbox}
onPress={() => setAutoGrow((a) => !a)}
>
<Text style={styles.streamCheckboxText}>
{autoGrow
? "☑ Auto-grow (no fixed height)"
: "☐ Fixed height (will downscale)"}
</Text>
</Pressable>
<View style={[styles.streamStage, autoGrow && styles.streamStageAuto]}>
<RaTeXView latex={latex} fontSize={20} displayMode={true} />
</View>
</View>
);
}

// ─── Case #120: useLayoutEffect measurement drives an absolute decoration ─────
// https://github.com/erweixin/RaTeX/issues/120
//
Expand Down Expand Up @@ -588,6 +667,7 @@ export default function App() {
<Text style={styles.buttonText}>Force Re-mount (key={key + 1})</Text>
</Pressable>

<CaseStreamingMath />
<CaseDetachCancel />
<CaseLayoutEffectMeasure />
<CasePR45Smoke />
Expand Down Expand Up @@ -696,6 +776,45 @@ const styles = StyleSheet.create({
marginBottom: 8,
paddingBottom: 12,
},
streamCard: {
flex: 0,
marginHorizontal: 12,
marginTop: 8,
marginBottom: 8,
paddingBottom: 12,
},
streamHint: {
fontSize: 11,
color: "#4b5563",
paddingHorizontal: 12,
marginBottom: 8,
},
streamStage: {
// Fixed height + top/left alignment: the card never resizes and the
// formula's top-left corner never moves, so the only thing that CAN move
// is the content inside the RaTeXView — the artifact under test.
// Deliberately shorter than the full 8-line block (~363dp at fontSize 20):
// the last appends also exercise the clamped case, where the content must
// scale to fit its container uniformly and without flicker.
height: 340,
alignItems: "flex-start",
justifyContent: "flex-start",
paddingHorizontal: 12,
overflow: "hidden",
},
streamStageAuto: {
// No fixed height: the stage grows with the content, exercising the
// auto-sizing streaming path (no clamp, no scale-to-fit).
height: "auto",
},
streamCheckbox: {
paddingHorizontal: 12,
marginBottom: 8,
},
streamCheckboxText: {
fontSize: 13,
color: "#111827",
},
detachCard: {
flex: 0,
marginHorizontal: 12,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
namespace facebook::react {

// Serialize the props the Kotlin measure() needs. Color does not affect the
// measured size, so it is intentionally omitted.
// measured size, but it IS part of the parse-cache key (the Rust engine bakes
// it into the DisplayList), so it must be forwarded for the entry produced by
// measure() to be reusable by the view's synchronous render.
inline folly::dynamic toDynamic(const RaTeXViewProps& props) {
folly::dynamic serializedProps = folly::dynamic::object();
serializedProps["latex"] = props.latex;
serializedProps["fontSize"] = props.fontSize;
serializedProps["displayMode"] = props.displayMode;
if (props.color) {
// On Android, Color is the ARGB int32 — same representation Kotlin uses.
serializedProps["color"] = *props.color;
}
return serializedProps;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ class RaTeXException(message: String) : Exception(message)
*/
object RaTeXEngine {

// MARK: - Parse cache
//
// Parsing is deterministic in (latex, displayMode, color) — the DisplayList is in
// em units, so fontSize is applied later by RaTeXRenderer and is NOT part of the
// key. The cache lets the Fabric measure pass (background layout thread) and the
// view's render share one parse, and lets RaTeXView swap its renderer
// synchronously on the main thread without ever parsing there.
//
// Sizing: entries are a few KB. The cap is generous because eviction between a
// view's measure() and its setLatex (e.g. a batch re-committing many formulas)
// would silently drop that view back to the async render path.

private data class ParseKey(val latex: String, val displayMode: Boolean, val colorArgb: Int)

private const val CACHE_MAX_ENTRIES = 128

private val cacheLock = Any()
private val cache = object : LinkedHashMap<ParseKey, DisplayList>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ParseKey, DisplayList>) =
size > CACHE_MAX_ENTRIES
}

/**
* Pure cache lookup — never parses. Safe and cheap on the main thread.
* @return the cached [DisplayList], or null if this input has not been parsed yet.
*/
fun lookupCached(
latex: String,
displayMode: Boolean = true,
@ColorInt color: Int = Color.BLACK,
): DisplayList? = synchronized(cacheLock) { cache[ParseKey(latex, displayMode, color)] }

/**
* Like [parseBlocking], but consults the cache first and stores the result.
* The parse itself runs outside the cache lock so a slow parse on one thread
* never blocks [lookupCached] on another; racing duplicate parses of the same
* key are acceptable (the output is identical, last put wins).
*/
fun parseCached(
latex: String,
displayMode: Boolean = true,
@ColorInt color: Int = Color.BLACK,
): DisplayList {
lookupCached(latex, displayMode, color)?.let { return it }
val displayList = parseBlocking(latex, displayMode, color)
synchronized(cacheLock) { cache[ParseKey(latex, displayMode, color)] = displayList }
return displayList
}

private fun rgbaFloatArray(@ColorInt color: Int): FloatArray = floatArrayOf(
((color ushr 16) and 0xff) / 255f,
((color ushr 8) and 0xff) / 255f,
Expand Down Expand Up @@ -72,7 +121,7 @@ object RaTeXEngine {
latex: String,
displayMode: Boolean = true,
@ColorInt color: Int = Color.BLACK,
): DisplayList = withContext(Dispatchers.Default) { parseBlocking(latex, displayMode, color) }
): DisplayList = withContext(Dispatchers.Default) { parseCached(latex, displayMode, color) }

/**
* Blocking variant of [parse]. Safe to call on any background thread.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ object RaTeXFontLoader {
private val fontsLoaded = AtomicBoolean(false)
private val loadLock = Any()

/** Whether fonts have been loaded (cheap atomic read; safe on the main thread). */
@JvmStatic
val isLoaded: Boolean get() = fontsLoaded.get()

/** KaTeX font IDs (Rust FontId.as_str()) → TTF filename without path. */
private val fontFileNames = listOf(
"AMS-Regular" to "KaTeX_AMS-Regular.ttf",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class RaTeXView @JvmOverloads constructor(

// MARK: - Public properties

/** LaTeX math-mode string to render. Setting this triggers an async re-render. */
/** LaTeX math-mode string to render. Setting this triggers a re-render. */
var latex: String = ""
set(value) {
if (field == value) return
Expand All @@ -56,7 +56,7 @@ class RaTeXView @JvmOverloads constructor(

/**
* Font size in density-independent units (dp), matching React Native / iOS points.
* Setting this triggers an async re-render.
* Setting this triggers a re-render.
*/
var fontSize: Float = 24f
set(value) {
Expand All @@ -67,7 +67,7 @@ class RaTeXView @JvmOverloads constructor(

/**
* Rendering mode. `true` (default) for display/block style (`$$...$$`);
* `false` for inline/text style (`$...$`). Setting this triggers an async re-render.
* `false` for inline/text style (`$...$`). Setting this triggers a re-render.
*/
var displayMode: Boolean = true
set(value) {
Expand All @@ -91,6 +91,17 @@ class RaTeXView @JvmOverloads constructor(
/** Called on the main thread when content size is known (width/height in dp). */
var onContentSizeChange: ((width: Double, height: Double) -> Unit)? = null

/**
* When true, an external layout system owns this view's frame (React Native's
* Fabric, which sizes the view from the shadow node's measure and assigns the
* frame directly). Content changes then skip [requestLayout]: the classic
* Android traversal it triggers would re-run [onMeasure] and could override the
* externally assigned frame (e.g. escape a Yoga size clamp). When false
* (standalone XML / programmatic use), [requestLayout] is the only way the view
* can resize to new content, so it must run.
*/
var sizingManagedExternally: Boolean = false

// MARK: - Private state

private var renderer: RaTeXRenderer? = null
Expand All @@ -100,6 +111,20 @@ class RaTeXView @JvmOverloads constructor(
// MARK: - Measure

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (sizingManagedExternally) {
// The frame is assigned by RN (Fabric updateLayoutMetrics). A classic
// Android traversal — triggered by ANY sibling's requestLayout — must
// not resize this view away from that frame: reporting the desired
// content size here would let the view grow past a Yoga clamp and then
// snap back on the next Fabric layout, a visible scale flip. Report the
// current frame instead (resolveSize still honors EXACTLY specs).
setMeasuredDimension(
resolveSize(width, widthMeasureSpec),
resolveSize(height, heightMeasureSpec),
)
return
}

val r = renderer
val desiredWidth = max(
(r?.widthPx?.let { ceil(it).toInt() } ?: 0) + paddingLeft + paddingRight,
Expand Down Expand Up @@ -171,39 +196,74 @@ class RaTeXView @JvmOverloads constructor(

private fun rerender() {
renderJob?.cancel()
renderJob = null
if (latex.isBlank()) {
renderer = null
requestLayout()
requestSelfLayout()
invalidate()
return
}

// Fast path: swap the renderer synchronously so the content changes in the
// same frame as the Fabric-assigned size. On the new architecture the shadow
// node's measure pass has already parsed this exact (latex, displayMode,
// color) during the commit — before setLatex reaches this view — so the
// lookup is a guaranteed hit and the main thread never parses. Without this,
// the box grows one frame before the content does and onDraw re-centers the
// stale (shorter) content inside the taller box: on streaming updates every
// already-rendered line visibly nudges down, then snaps back.
//
// Fonts are only used at draw time, but a formula rendered before they load
// would draw blank glyphs with nothing to trigger a redraw — so the fast
// path requires fonts to be loaded; otherwise fall through to the async
// path, which loads them first (only ever the case on app cold start).
if (RaTeXFontLoader.isLoaded) {
val dl = RaTeXEngine.lookupCached(latex, displayMode, color)
if (dl != null) {
applyRenderer(dl)
return
}
}

renderJob = scope.launch {
try {
withContext(Dispatchers.IO) { RaTeXFontLoader.ensureLoaded(context) }
val dl = RaTeXEngine.parse(latex, displayMode, color)
// RN passes logical size (dp); convert to px so physical size matches iOS points.
val density = context.resources.displayMetrics.density
val fontSizePx = fontSize * density
val r = RaTeXRenderer(dl, fontSizePx) { RaTeXFontLoader.getTypeface(it) }
renderer = r
requestLayout()
invalidate()
val widthDp = r.widthPx / density
val heightDp = r.totalHeightPx / density
onContentSizeChange?.invoke(widthDp.toDouble(), heightDp.toDouble())
applyRenderer(dl)
} catch (e: CancellationException) {
// Not a render error: the job was cancelled (detach). Rethrow so the coroutine
// machinery completes cancellation; onAttachedToWindow restarts the render.
throw e
} catch (e: RaTeXException) {
renderer = null
requestLayout(); invalidate()
requestSelfLayout(); invalidate()
onError?.invoke(e)
} catch (e: Throwable) {
renderer = null
requestLayout(); invalidate()
requestSelfLayout(); invalidate()
onError?.invoke(RaTeXException(e.message ?: "unknown error"))
}
}
}

/**
* Request a layout pass to adopt the new content size — unless the frame is
* owned by an external layout system (see [sizingManagedExternally]).
*/
private fun requestSelfLayout() {
if (!sizingManagedExternally) requestLayout()
}

/** Install a renderer for [dl] and publish layout + content size. Main thread only. */
private fun applyRenderer(dl: DisplayList) {
// RN passes logical size (dp); convert to px so physical size matches iOS points.
val density = context.resources.displayMetrics.density
val r = RaTeXRenderer(dl, fontSize * density) { RaTeXFontLoader.getTypeface(it) }
renderer = r
requestSelfLayout()
invalidate()
val widthDp = r.widthPx / density
val heightDp = r.totalHeightPx / density
onContentSizeChange?.invoke(widthDp.toDouble(), heightDp.toDouble())
}
}
Loading
Loading