Skip to content

Fix/android detach cancels render#127

Merged
erweixin merged 3 commits into
erweixin:mainfrom
istarkov:fix/android-detach-cancels-render
Jul 9, 2026
Merged

Fix/android detach cancels render#127
erweixin merged 3 commits into
erweixin:mainfrom
istarkov:fix/android-detach-cancels-render

Conversation

@istarkov

@istarkov istarkov commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The bug

On Android a formula can end up permanently invisible: it occupies exactly the right
space (measurement is synchronous and fine), draws nothing, and only a full remount
brings it back. onError fires with the confusing StandaloneCoroutine was cancelled.

Reproduction

Got this on real App, and it's reproducible ;-)

demo/react-native-expo (upgraded to Expo SDK 57 / RN 0.86) has a new Detach-cancel
case that reproduces this deterministically: 32 formulas mount inside opacity: 0
wrappers that are revealed one frame later. Dropping the style makes the wrappers
eligible for Fabric view flattening, so the commit re-parents them
(SurfaceMountingManager.removeViewAt + addView) — transiently detaching every formula
while its render job is still in flight. Kill + relaunch the app (cold start is the most
reliable: the first job blocks on the KaTeX font load, keeping all 32 pending):

Broken (this branch's parent) Fixed (this branch)
red dot, rendered 0/32 · errors 32, blank boxes green dot, rendered 32/32 · errors 0
broken fixed

Root cause

  1. onDetachedFromWindow cancels the pending renderJob — but the detach is only
    transient (the flattening re-parent above; also view moves, RecyclerView, RN
    #38567). The view comes right
    back; its job doesn't.
  2. Nothing restarts it: every rerender() trigger is a prop setter guarded by
    if (field == value) return, and the props never change again → blank forever.
  3. catch (e: Throwable) swallows the CancellationException and reports it as a render
    error. For a CancellationException to terminate a coroutine properly it must reach
    the top of the call stack uncaught.

The fix

Applied to RaTeXView.kt + RaTeXInlineView.kt (react-native) and the standalone
platforms/android RaTeXView.kt — the same pattern Coil uses in
ViewTargetRequestManager
(cancel on detach is only safe when paired with restart on attach):

  • onAttachedToWindow re-kicks the render when nothing is rendered, content is
    non-blank, and no job is in flight (a normal attach is a no-op).
  • Rethrow CancellationException instead of turning it into an error state + bogus
    onError (see kotlinx.coroutines#1814).
  • Standalone copy only: cancel just the job on detach, not the whole scope (+
    SupervisorJob) — a cancelled scope ignores every future launch, killing the view
    for good.

Validation

  • Demo case above: 0/32 → 32/32 on cold start.
  • Consumer app (RN 0.86 + expo-router, Pixel emulator): cold deep-link into a 13-formula
    document went from mostly-blank to all rendered, pixel-identical across a remount
    cycle (pr-screenshots/android-broken-blank-formulas.png /
    android-fixed-all-formulas.png).
  • iOS unaffected (no detach-cancel lifecycle in its render path).

Consumer-side tip (independent of this fix): pinning the revealing wrapper with
collapsable={false} keeps its flattening status stable and avoids the detach wave
entirely.

istarkov added 3 commits July 8, 2026 08:14
32 formulas mount inside opacity:0 wrappers revealed one frame later; the
style drop flips the wrappers' Fabric view-flattening status, so the commit
re-parents them and transiently detaches every formula while its render job
is in flight. On a cold start (fonts loading, all jobs pending) the broken
library shows rendered 0/32 with 32 'StandaloneCoroutine was cancelled'
errors; the fixed library renders 32/32.
…he render job

A transient detach (Fabric re-parenting on a view-flattening change, view
moves, RecyclerView) cancelled the pending renderJob, and nothing ever
restarted it — every rerender() trigger is a prop setter guarded by
field == value, so the view kept its laid-out size but stayed blank forever,
reporting the cancellation through onError.

- onAttachedToWindow re-kicks the render when nothing is rendered and no job
  is in flight (same pattern as Coil's ViewTargetRequestManager)
- rethrow CancellationException instead of converting it into an error state;
  it must reach the top of the call stack uncaught
- standalone platforms/android copy: cancel only the job on detach, not the
  whole scope (+ SupervisorJob) — a cancelled scope ignores every future
  launch

erweixin commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Thank you for putting this PR together — this is an excellent, very carefully documented fix.

Really thoughtful contribution. Thanks again for digging into this and for the thorough validation notes.

@erweixin erweixin merged commit 7581a58 into erweixin:main Jul 9, 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