From 62155f3ba73764f6985094450b9ad32330fcc0bd Mon Sep 17 00:00:00 2001 From: Ivan Starkov Date: Thu, 9 Jul 2026 12:50:18 +0300 Subject: [PATCH 1/5] demo(expo): add streaming-math case where rendered lines jump on Android Appends one line at a time to a growing \begin{aligned} block on a timer, simulating LLM streaming. On Android (Fabric) each append grows the box synchronously at commit while the content updates a frame later, so the already-rendered lines visibly move, then snap back. The stage is fixed-height and top-aligned so the only thing that can move is the content inside the RaTeXView; it is deliberately ~3dp shorter than the full 8-line block so the final append also exercises the clamped (scale-to-fit) case. Claude-Session: https://claude.ai/code/session_01KJqAxc7QTjeGX2gZ73bykX --- demo/react-native-expo/App.tsx | 119 +++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) 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, From 6d5d96ad5cc2d804af8c2e70b22c62d5a277fa77 Mon Sep 17 00:00:00 2001 From: Ivan Starkov Date: Thu, 9 Jul 2026 12:51:31 +0300 Subject: [PATCH 2/5] fix(android): render synchronously with the commit, via a parse cache shared with measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Fabric, a latex change grows the box synchronously (the shadow node's measure parses at commit time) while the view swapped its renderer in a coroutine a frame later, so onDraw re-centered the stale content inside the new box — on streaming updates every already-rendered line visibly moved, then snapped back. iOS has always rendered synchronously in the prop setter and never had this. Add a small LRU cache keyed by (latex, displayMode, colorArgb) — the complete parse input; the DisplayList is em-based so fontSize stays out of the key — and route both the Fabric measure and the view's render through it. Since measure runs before mount, the view's re-render is a guaranteed cache hit and swaps the renderer synchronously in the same mount transaction as the size: the main thread never parses. measure() now also receives the real color prop (previously omitted as size-irrelevant) so its cache entry matches the render's key. Net parse work per update drops from two parses (measure + render) to one. The async path is kept as-is for cache misses: the old architecture, XML/ programmatic usage, and the first render on a cold process (fonts not loaded yet — a formula drawn before fonts load would show blank glyphs with nothing to trigger a redraw). Claude-Session: https://claude.ai/code/session_01KJqAxc7QTjeGX2gZ73bykX --- .../components/RNRaTeXSpec/conversions.h | 8 ++- .../src/main/kotlin/io/ratex/RaTeXEngine.kt | 51 +++++++++++++++++- .../main/kotlin/io/ratex/RaTeXFontLoader.kt | 4 ++ .../src/main/kotlin/io/ratex/RaTeXView.kt | 53 ++++++++++++++----- .../kotlin/io/ratex/RaTeXViewManager.kt | 9 +++- 5 files changed, 108 insertions(+), 17 deletions(-) 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..a1805e01 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) { @@ -171,26 +171,40 @@ class RaTeXView @JvmOverloads constructor( private fun rerender() { renderJob?.cancel() + renderJob = null if (latex.isBlank()) { renderer = null requestLayout() 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. @@ -206,4 +220,17 @@ class RaTeXView @JvmOverloads constructor( } } } + + /** 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 + requestLayout() + 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..62d098d9 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 @@ -74,7 +74,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 +96,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) { From 22b6466e59d284d4ac7e0ecb66d1861bfea0fc36 Mon Sep 17 00:00:00 2001 From: Ivan Starkov Date: Thu, 9 Jul 2026 12:52:26 +0300 Subject: [PATCH 3/5] fix: give the RN-assigned frame a single owner (no JS self-size on Fabric, no self-requestLayout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With rendering now synchronous, two other actors were still resizing the view after Fabric assigned its frame, each visible as a one-frame scale flip on streaming updates (content drawn scaled into a box owned by someone else, then snapping back): - The JS wrapper cached the native-reported content size and re-applied it as an explicit width/height style one commit later. The event carries the UNCONSTRAINED size, so this overrode whatever clamp the parent imposed during the measured pass (box observed flipping 945->952px in a 360dp container), and until the reset effect ran, a new latex committed with the PREVIOUS formula's explicit size. On Fabric the whole pass is redundant — the shadow node's measureContent already sizes the component synchronously — so it is now old-architecture only, which also removes one JS commit per update. - The Android view itself: applyRenderer's requestLayout, and onMeasure answering ANY layout traversal (triggered by any sibling's requestLayout) with the desired content size, let the view grow past a Yoga clamp with no commit in between. When RN manages the view (sizingManagedExternally, set by the new-arch view manager) content changes now only invalidate, and onMeasure reports the current Fabric-assigned frame. Standalone XML/ programmatic usage and the old architecture keep the previous behavior — there requestLayout/onMeasure are the only sizing mechanism. Claude-Session: https://claude.ai/code/session_01KJqAxc7QTjeGX2gZ73bykX --- .../src/main/kotlin/io/ratex/RaTeXView.kt | 41 +++++++++++++++++-- .../kotlin/io/ratex/RaTeXViewManager.kt | 4 ++ platforms/react-native/src/RaTeXView.tsx | 31 ++++++++++---- 3 files changed, 65 insertions(+), 11 deletions(-) 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 a1805e01..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 @@ -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, @@ -174,7 +199,7 @@ class RaTeXView @JvmOverloads constructor( renderJob = null if (latex.isBlank()) { renderer = null - requestLayout() + requestSelfLayout() invalidate() return } @@ -211,23 +236,31 @@ class RaTeXView @JvmOverloads constructor( 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 - requestLayout() + requestSelfLayout() invalidate() val widthDp = r.widthPx / density val heightDp = r.totalHeightPx / density 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 62d098d9..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) diff --git a/platforms/react-native/src/RaTeXView.tsx b/platforms/react-native/src/RaTeXView.tsx index a0187cca..e16d731e 100644 --- a/platforms/react-native/src/RaTeXView.tsx +++ b/platforms/react-native/src/RaTeXView.tsx @@ -41,6 +41,18 @@ 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 = (global as {nativeFabricUIManager?: unknown}) + .nativeFabricUIManager != null; + export function RaTeXView({ latex, fontSize = 24, @@ -57,18 +69,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], From 6be1bcb9985c17c5563835274240babdf2e9cca5 Mon Sep 17 00:00:00 2001 From: Ivan Starkov Date: Thu, 9 Jul 2026 18:56:19 +0300 Subject: [PATCH 4/5] Add ref support, change global onto globalThis --- platforms/react-native/src/RaTeXView.tsx | 12 ++++++++++-- platforms/react-native/src/index.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/platforms/react-native/src/RaTeXView.tsx b/platforms/react-native/src/RaTeXView.tsx index e16d731e..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; @@ -50,8 +55,9 @@ export interface RaTeXViewProps { // 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 = (global as {nativeFabricUIManager?: unknown}) - .nativeFabricUIManager != null; +const IS_FABRIC = + (globalThis as {nativeFabricUIManager?: unknown}).nativeFabricUIManager != + null; export function RaTeXView({ latex, @@ -61,6 +67,7 @@ export function RaTeXView({ style, onError, onContentSizeChange, + ref, }: RaTeXViewProps): React.JSX.Element { const inheritedColor = useContext(RaTeXColorContext); const [contentSize, setContentSize] = useState<{ @@ -109,6 +116,7 @@ export function RaTeXView({ return ( Date: Thu, 9 Jul 2026 22:10:31 +0300 Subject: [PATCH 5/5] Compensate ceil on IOS. --- platforms/react-native/ios/RaTeXViewManager.mm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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); } };