Skip to content

fix: Android sync render on measure#129

Merged
erweixin merged 5 commits into
erweixin:mainfrom
istarkov:fix/android-sync-render-on-measure
Jul 10, 2026
Merged

fix: Android sync render on measure#129
erweixin merged 5 commits into
erweixin:mainfrom
istarkov:fix/android-sync-render-on-measure

Conversation

@istarkov

@istarkov istarkov commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.

bug fast bug slomo
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 math case 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.

bug fast bug slomo
fix-2.mp4
fix-2-frames.mp4

Cause of flicker: the TSX wrapper re-applied every onContentSizeChange measurement as explicit width/height style. 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 (measureContent already sizes the view), so it's now old-arch-only. The Android view likewise skips requestLayout()/onMeasure self-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

istarkov added 3 commits July 9, 2026 14:54
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
@istarkov istarkov changed the title Fix/android sync render on measure fix: Android sync render on measure Jul 9, 2026
@istarkov

istarkov commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

PS: Added ref support to the same PR.

@erweixin erweixin merged commit 14bb6e9 into erweixin:main Jul 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants