diff --git a/demo/react-native-expo/App.tsx b/demo/react-native-expo/App.tsx
index c09a887d..c82ac804 100644
--- a/demo/react-native-expo/App.tsx
+++ b/demo/react-native-expo/App.tsx
@@ -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 (
+
+
+
+ Streaming math — watch rendered lines jump (Android)
+
+
+
+ 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.
+
+ setRunning((r) => !r)}
+ >
+ {running ? "Pause" : "Resume"}
+
+ setAutoGrow((a) => !a)}
+ >
+
+ {autoGrow
+ ? "☑ Auto-grow (no fixed height)"
+ : "☐ Fixed height (will downscale)"}
+
+
+
+
+
+
+ );
+}
+
// ─── Case #120: useLayoutEffect measurement drives an absolute decoration ─────
// https://github.com/erweixin/RaTeX/issues/120
//
@@ -588,6 +667,7 @@ export default function App() {
Force Re-mount (key={key + 1})
+
@@ -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,
diff --git a/platforms/react-native/android/src/main/jni/react/renderer/components/RNRaTeXSpec/conversions.h b/platforms/react-native/android/src/main/jni/react/renderer/components/RNRaTeXSpec/conversions.h
index 90d897e0..dcaa353e 100644
--- a/platforms/react-native/android/src/main/jni/react/renderer/components/RNRaTeXSpec/conversions.h
+++ b/platforms/react-native/android/src/main/jni/react/renderer/components/RNRaTeXSpec/conversions.h
@@ -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;
}
diff --git a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXEngine.kt b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXEngine.kt
index 015afba8..c28574e2 100644
--- a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXEngine.kt
+++ b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXEngine.kt
@@ -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(64, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry) =
+ 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,
@@ -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.
diff --git a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXFontLoader.kt b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXFontLoader.kt
index 44b157e8..4687de34 100644
--- a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXFontLoader.kt
+++ b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXFontLoader.kt
@@ -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",
diff --git a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXView.kt b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXView.kt
index 23cba423..fe1cb209 100644
--- a/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXView.kt
+++ b/platforms/react-native/android/src/main/kotlin/io/ratex/RaTeXView.kt
@@ -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
@@ -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) {
@@ -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) {
@@ -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
@@ -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,
@@ -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())
+ }
}
diff --git a/platforms/react-native/android/src/newarch/kotlin/io/ratex/RaTeXViewManager.kt b/platforms/react-native/android/src/newarch/kotlin/io/ratex/RaTeXViewManager.kt
index 49ff4617..4ebf7b97 100644
--- a/platforms/react-native/android/src/newarch/kotlin/io/ratex/RaTeXViewManager.kt
+++ b/platforms/react-native/android/src/newarch/kotlin/io/ratex/RaTeXViewManager.kt
@@ -33,6 +33,10 @@ class RaTeXViewManager(private val reactContext: ReactApplicationContext) :
override fun createViewInstance(ctx: ThemedReactContext): RaTeXView {
val view = RaTeXView(ctx)
+ // Fabric owns the frame (assigned from the shadow node's measure); the view
+ // must not requestLayout itself, or the classic Android traversal could
+ // override the Yoga-assigned size (e.g. escape a style width/height clamp).
+ view.sizingManagedExternally = true
view.onError = { exception ->
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(ctx, view.id)
val surfaceId = UIManagerHelper.getSurfaceId(ctx)
@@ -74,7 +78,9 @@ class RaTeXViewManager(private val reactContext: ReactApplicationContext) :
// node's measureContent via FabricUIManager.measure. Gives the view its real
// size on the first commit (e.g. at JS useLayoutEffect) instead of only after
// the async onContentSizeChange event. Parsing is thread-safe and fonts are not
- // needed for measurement; color does not affect size, so it is ignored here.
+ // needed for measurement. Color does not affect size, but it is part of the
+ // parse-cache key — parsing with the view's actual color makes this entry
+ // reusable by RaTeXView's synchronous render on the main thread.
override fun measure(
context: Context,
localData: ReadableMap?,
@@ -94,9 +100,12 @@ class RaTeXViewManager(private val reactContext: ReactApplicationContext) :
}
val displayMode =
if (props?.hasKey("displayMode") == true) props.getBoolean("displayMode") else true
+ val color =
+ if (props?.hasKey("color") == true && !props.isNull("color")) props.getInt("color")
+ else Color.BLACK
return try {
val density = context.resources.displayMetrics.density
- val displayList = RaTeXEngine.parseBlocking(latex, displayMode, Color.BLACK)
+ val displayList = RaTeXEngine.parseCached(latex, displayMode, color)
val renderer = RaTeXRenderer(displayList, fontSize * density)
YogaMeasureOutput.make(renderer.widthPx / density, renderer.totalHeightPx / density)
} catch (e: Throwable) {
diff --git a/platforms/react-native/ios/RaTeXViewManager.mm b/platforms/react-native/ios/RaTeXViewManager.mm
index 659de4cc..38822f20 100644
--- a/platforms/react-native/ios/RaTeXViewManager.mm
+++ b/platforms/react-native/ios/RaTeXViewManager.mm
@@ -10,6 +10,7 @@
#import
#import
#import
+#include
#else
#import "RaTeXViewManager.h"
#import
@@ -79,7 +80,12 @@ static ShadowNodeTraits BaseTraits() {
CGSize measured = [RaTeXMeasure measureLatex:latex
fontSize:static_cast(props.fontSize)
displayMode:props.displayMode ? YES : NO];
- facebook::react::Size size{static_cast(measured.width), static_cast(measured.height)};
+ // Snap up to the pixel grid so Yoga's position-dependent edge rounding can't vary the
+ // reported height across placements of the same formula. Uses Yoga's own scale factor.
+ Float scale = layoutContext.pointScaleFactor > 0 ? layoutContext.pointScaleFactor : 1;
+ Float width = std::ceil(static_cast(measured.width) * scale) / scale;
+ Float height = std::ceil(static_cast(measured.height) * scale) / scale;
+ facebook::react::Size size{width, height};
return layoutConstraints.clamp(size);
}
};
diff --git a/platforms/react-native/src/RaTeXView.tsx b/platforms/react-native/src/RaTeXView.tsx
index a0187cca..2a6e3d5e 100644
--- a/platforms/react-native/src/RaTeXView.tsx
+++ b/platforms/react-native/src/RaTeXView.tsx
@@ -27,8 +27,13 @@ export function RaTeXProvider({
);
}
+/** Instance handle of the underlying native view (measure, measureInWindow, …). */
+export type RaTeXViewRef = React.ComponentRef;
+
export interface RaTeXViewProps {
latex: string;
+ /** Ref to the underlying native view (React 19 ref-as-prop). */
+ ref?: React.Ref;
fontSize?: number;
/** true (default) = display/block style ($$...$$); false = inline/text style ($...$). */
displayMode?: boolean;
@@ -41,6 +46,19 @@ export interface RaTeXViewProps {
}) => void;
}
+// On the new architecture (Fabric) the native component self-sizes synchronously
+// during layout via the shadow node's measureContent, so feeding the async
+// onContentSizeChange measurement back as an explicit width/height style is
+// redundant — and actively harmful: the event carries the UNCONSTRAINED content
+// size, so re-applying it as an explicit style overrides whatever clamp the
+// parent imposed during the measured pass, one commit later. When the content
+// doesn't fit its container that disagreement is visible as a scale flip on
+// every update. The JS self-sizing pass exists only for the old architecture,
+// which has no shadow-node measure.
+const IS_FABRIC =
+ (globalThis as {nativeFabricUIManager?: unknown}).nativeFabricUIManager !=
+ null;
+
export function RaTeXView({
latex,
fontSize = 24,
@@ -49,6 +67,7 @@ export function RaTeXView({
style,
onError,
onContentSizeChange,
+ ref,
}: RaTeXViewProps): React.JSX.Element {
const inheritedColor = useContext(RaTeXColorContext);
const [contentSize, setContentSize] = useState<{
@@ -57,18 +76,23 @@ export function RaTeXView({
} | null>(null);
const resolvedColor = color ?? inheritedColor;
- // When inputs change, drop the cached measurement so the view can shrink/grow
- // immediately instead of keeping a stale width/height until the next event arrives.
+ // Old architecture only (contentSize is never set on Fabric): when inputs
+ // change, drop the cached measurement so the view can shrink/grow instead of
+ // keeping a stale width/height until the next event arrives.
useEffect(() => {
- setContentSize(null);
+ if (!IS_FABRIC) {
+ setContentSize(null);
+ }
}, [latex, fontSize, displayMode, resolvedColor]);
const handleContentSizeChange = useCallback(
(e: {nativeEvent: {width: number; height: number}}) => {
- setContentSize({
- width: e.nativeEvent.width,
- height: e.nativeEvent.height,
- });
+ if (!IS_FABRIC) {
+ setContentSize({
+ width: e.nativeEvent.width,
+ height: e.nativeEvent.height,
+ });
+ }
onContentSizeChange?.(e);
},
[onContentSizeChange],
@@ -92,6 +116,7 @@ export function RaTeXView({
return (