Skip to content

fix(android): key onNativeLayout guard on layoutHolder so transient startup heights self-heal#556

Open
foxojs wants to merge 2 commits into
callstack:mainfrom
foxojs:fix/android-tab-height-latch
Open

fix(android): key onNativeLayout guard on layoutHolder so transient startup heights self-heal#556
foxojs wants to merge 2 commits into
callstack:mainfrom
foxojs:fix/android-tab-height-latch

Conversation

@foxojs

@foxojs foxojs commented Jul 20, 2026

Copy link
Copy Markdown

PR Description

Fixes #555 — a session-long Android layout latch.

The layout-change listener in RCTTabView.kt guards re-reporting on the outer view's size (right - left / bottom - top) but reports the inner layoutHolder's size to JS. During app startup these can disagree: the outer frame settles at its final size while layoutHolder is still mid-layout at a transient (roughly half-window) height. If the listener fires in that window:

  1. the transient layoutHolder height is reported to JS, and
  2. the outer size is stored in lastReportedSize.

When layoutHolder finishes laying out, the outer size is unchanged, so the guard never permits a re-report. Every tab screen stays styled with the transient height for the entire session — content clipped to roughly half the screen with dead space below, while the tab bar, absolutely-positioned siblings, and pushed stack screens render normally. A force-close sometimes clears it (the race is per cold start). We observed this persistently in production on a Pixel 6a / Android 16.

This is a regression from #283, which changed the reported size from the outer view to layoutHolder without changing the guard.

The fix: key the guard on layoutHolder's own dimensions — the same view whose size is reported. Any later correct layout is then detected as a change and re-reported, so the view self-heals; worst case the wrong height lives for a single frame (measured 5 ms from restore to re-report in our reproduction).

How to test?

The race is timing-dependent and hard to hit on demand, so we made it deterministic: temporarily force layoutHolder to a transient non-final height at the moment the listener first fires, then restore it — simulating exactly what the race produces. Inside the post { ... } block in init, after addOnLayoutChangeListener:

// repro only: simulate the startup race deterministically
val lp = layoutHolder.layoutParams as LayoutParams
lp.weight = 0f
lp.height = (resources.displayMetrics.heightPixels * 0.4).toInt()
layoutHolder.layoutParams = lp
postDelayed({
  val lp2 = layoutHolder.layoutParams as LayoutParams
  lp2.weight = 1f
  lp2.height = 0
  layoutHolder.layoutParams = lp2
}, 1500)

With a log line on each onNativeLayoutListener invocation, current main produces (Pixel 6a emulator):

15:45:04.908  shrinking layoutHolder to 40%
15:45:04.941  REPORTED to JS: 411x365dp (holder=1080x960px)
15:45:06.419  restoring layoutHolder to full
              -- no further report; tab screens stay clipped at 365dp for the session

With this PR, identical perturbation:

15:49:44.720  shrinking layoutHolder to 40%
15:49:44.741  REPORTED to JS: 411x365dp (holder=1080x960px)
15:49:46.238  restoring layoutHolder to full
15:49:46.243  REPORTED to JS: 411x809dp (holder=1080x2126px)   <- self-heals in 5 ms

Remove the perturbation after testing. Normal behaviour is unchanged: the guard fires on the same real size changes as before (rotation, window resize, keyboard), just keyed on the reported view.

Screenshots

Same app, same injected transient — only the guard differs.

Before (current main) — latched After (this PR) — self-healed
Tab screen content latched at ~40% of the window with dead space below; tab bar and floating elements unaffected Full-height tab screen after the fix re-reports the corrected layoutHolder size

🤖 Generated with Claude Code

foxojs and others added 2 commits July 20, 2026 16:05
…tartup heights self-heal

The layout-change listener guards re-reporting on the OUTER view's size
(right - left / bottom - top) but reports the INNER layoutHolder's size to
JS. During app startup these can disagree: the outer frame settles at its
final size while layoutHolder is still mid-layout at a transient (roughly
half-window) height. When the listener fires in that window, the transient
layoutHolder height is reported to JS and the outer size is stored in
lastReportedSize. Once layoutHolder finishes laying out, the outer size is
unchanged, so the guard never allows a re-report: every tab screen stays
styled with the transient height for the whole session (content clipped,
dead space below; stack screens and absolutely-positioned siblings are
unaffected). Observed persistently in production on a Pixel 6a / Android 16.

Key the guard on layoutHolder's own dimensions - the same view whose size is
reported - so any later correct layout is detected as a change and
re-reported. Worst case the wrong height lives for a single frame before the
next layout pass corrects it (measured 5 ms in a deterministic reproduction).

Regression introduced by callstack#283, which changed the reported size from the
outer view to layoutHolder without changing the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

[Android] Tab screens stay clipped at a transient startup height for the whole session (onNativeLayout guard/report size mismatch)

1 participant