fix: Android sync render on measure#129
Merged
erweixin merged 5 commits intoJul 10, 2026
Merged
Conversation
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
… shared with measure 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
…bric, no self-requestLayout) 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
Contributor
Author
|
PS: Added ref support to the same PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue
Streaming content into
RaTeXView(e.g. an LLM emitting math line by line) glitches on Android on every append.For one frame the old content is drawn inside the new taller box, then snaps into place.
android.mp4
streaming-math-nudge.mp4
iOS renders synchronously and never had these bugs. This PR takes the same approach on IOS: sync in most cases, async only as a fallback. Both artifacts are reproduced by a new
Streaming mathcase in the Expo demo.Fix 1: render in sync with the commit
Cause of jump 1: on Fabric the box resizes synchronously at commit (
measureContent), but rendering was async.measure()already parses every formula on the layout thread; it now stores the result in a small LRU cache keyed by(latex, displayMode, color). On the UI thread,rerender()does a pure cache lookup — a hit swaps the renderer in the same transaction that applies the layout. The async path stays as fallback for misses (old arch, fonts not loaded yet).Performance is a net win: Fabric parsed every update twice (measure + render), now once; the main thread only does a lookup; the cache is bounded (128 entries).
Fix 2: one owner for the view's size
The demo's fixed-height stage is deliberately a bit shorter than the full block to show this: without the fix, on each append the content scales down for one frame, then unscales back.
fix-2.mp4
fix-2-frames.mp4
Cause of flicker: the TSX wrapper re-applied every
onContentSizeChangemeasurement as explicitwidth/heightstyle. That size is unconstrained, so one commit later it overrode the parent's clamp — the box flipped between two sizes on every update.On Fabric this JS self-sizing is redundant (
measureContentalready sizes the view), so it's now old-arch-only. The Android view likewise skipsrequestLayout()/onMeasureself-sizing when Fabric owns the frame.Result
Appends are flicker-free: box and glyphs land in the same frame, and clamped content keeps one stable scale. Old architecture unchanged. Verified on Android emulator and iOS simulator.
No glitches.
Screen.Recording.2026-07-09.at.16.06.41.mov