From 665bd100c374ea6f150dea90782c5ff9fe0093a7 Mon Sep 17 00:00:00 2001 From: Adam Segal <> Date: Fri, 17 Jul 2026 15:15:17 -0700 Subject: [PATCH 1/2] fix(Android, Stack): exclude top inset from dummy header height when consumeTopInset is false The C++ dummy-layout measurement (findHeaderHeight -> ScreenDummyLayoutHelper. computeDummyLayout) unconditionally added the decor view top inset to the reported header height. The native CustomToolbar, however, only pads itself with the top inset when `legacyTopInsetBehavior || consumeTopInset` holds (introduced in #4220). As a result, any header that opts out of the top inset via `disableTopInsetApplication` (consumeTopInset == false) has its measured height over-reported by the status-bar / cutout inset, offsetting screen content by that amount on the first Fabric layout. Mirror the toolbar's condition in the measurement path: - pass `applyTopInset = legacyTopInsetBehavior || consumeTopInset` through JNI (computeDummyLayout signature (IZ)F -> (IZZ)F) - zero the top inset in the dummy measurement when applyTopInset is false - include applyTopInset in the measurement cache key so mixed-mode headers do not collide on a stale cached height Verify with Test4220: toggling `disableTopInsetApplication` should no longer offset the screen content below the header. --- .../utils/ScreenDummyLayoutHelper.kt | 19 ++++++++++++++----- .../rnscreens/RNSScreenShadowNode.cpp | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt index 2e96432cce..592571c07c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt @@ -176,6 +176,11 @@ internal class ScreenDummyLayoutHelper( * the cache while a measurement is in progress. * * @param fontSize font size value as passed from JS + * @param isTitleEmpty whether the header title is empty + * @param applyTopInset whether the native header applies the top inset (mirrors + * `legacyTopInsetBehavior || consumeTopInset` from [CustomToolbar]). When `false` + * (e.g. `disableTopInsetApplication`), the top inset must be excluded so the reported + * header height matches what actually renders. * @return header height in dp as consumed by Yoga */ @DoNotStrip @@ -183,6 +188,7 @@ internal class ScreenDummyLayoutHelper( private fun computeDummyLayout( fontSize: Int, isTitleEmpty: Boolean, + applyTopInset: Boolean, ): Float { if (!isLayoutInitialized) { val reactContext = @@ -198,7 +204,7 @@ internal class ScreenDummyLayoutHelper( } } - if (cache.hasKey(CacheKey(fontSize, isTitleEmpty))) { + if (cache.hasKey(CacheKey(fontSize, isTitleEmpty, applyTopInset))) { return cache.headerHeight } @@ -212,7 +218,7 @@ internal class ScreenDummyLayoutHelper( } val topLevelDecorView = currentActivity.window.decorView - val topInset = getDecorViewTopInset(topLevelDecorView) + val topInset = if (applyTopInset) getDecorViewTopInset(topLevelDecorView) else 0 // These dimensions are not accurate, as they do include navigation bar, however // it is ok for our purposes. @@ -242,11 +248,13 @@ internal class ScreenDummyLayoutHelper( // scenarios when layout violates measured dimensions. currentCoordinatorLayout.layout(0, 0, decorViewWidth, decorViewHeight) - // Include the top inset to account for the extra padding manually applied to the CustomToolbar. + // Include the top inset to account for the extra padding manually applied to the + // CustomToolbar. When the header opts out of the top inset (`applyTopInset == false`), + // `topInset` is 0 here so the measured height matches the rendered header. val totalAppBarLayoutHeight = currentAppBarLayout.height.toFloat() + topInset val headerHeight = PixelUtil.toDIPFromPixel(totalAppBarLayoutHeight) - cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight) + cache = CacheEntry(CacheKey(fontSize, isTitleEmpty, applyTopInset), headerHeight) return headerHeight } @@ -359,6 +367,7 @@ internal class ScreenDummyLayoutHelper( private data class CacheKey( val fontSize: Int, val isTitleEmpty: Boolean, + val applyTopInset: Boolean, ) private class CacheEntry( @@ -368,6 +377,6 @@ private class CacheEntry( fun hasKey(key: CacheKey) = cacheKey.fontSize != Int.MIN_VALUE && cacheKey == key companion object { - val EMPTY = CacheEntry(CacheKey(Int.MIN_VALUE, false), 0f) + val EMPTY = CacheEntry(CacheKey(Int.MIN_VALUE, false, true), 0f) } } diff --git a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp index 5ff5328c15..5e364b3ec8 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSScreenShadowNode.cpp @@ -32,7 +32,8 @@ static constexpr const char *kScreenDummyLayoutHelperClass = std::optional findHeaderHeight( const int fontSize, - const bool isTitleEmpty) { + const bool isTitleEmpty, + const bool applyTopInset) { JNIEnv *env = facebook::jni::Environment::current(); if (env == nullptr) { @@ -49,7 +50,7 @@ std::optional findHeaderHeight( } jmethodID computeDummyLayoutID = - env->GetMethodID(layoutHelperClass, "computeDummyLayout", "(IZ)F"); + env->GetMethodID(layoutHelperClass, "computeDummyLayout", "(IZZ)F"); if (computeDummyLayoutID == nullptr) { LOG(ERROR) << "[RNScreens] Failed to retrieve computeDummyLayout method ID"; @@ -76,7 +77,7 @@ std::optional findHeaderHeight( } jfloat headerHeight = env->CallFloatMethod( - packageInstance, computeDummyLayoutID, fontSize, isTitleEmpty); + packageInstance, computeDummyLayoutID, fontSize, isTitleEmpty, applyTopInset); return {headerHeight}; } @@ -101,13 +102,23 @@ void RNSScreenShadowNode::appendChild( *std::static_pointer_cast( headerConfigChild->getProps()); + // The native CustomToolbar only pads itself with the top inset when + // `legacyTopInsetBehavior || consumeTopInset` holds (see CustomToolbar.kt). + // The dummy measurement must mirror that condition, otherwise the header + // height is over-reported by the top inset whenever a header opts out via + // `disableTopInsetApplication` (consumeTopInset == false). + const bool applyTopInset = + headerProps.legacyTopInsetBehavior || headerProps.consumeTopInset; + // Translucent headers do not reserve vertical space, so applying the // estimated header correction can expose the previous screen at the // bottom during the first Fabric layout. const auto headerHeight = (headerProps.hidden || headerProps.translucent) ? 0.f : findHeaderHeight( - headerProps.titleFontSize, headerProps.title.empty()) + headerProps.titleFontSize, + headerProps.title.empty(), + applyTopInset) .value_or(0.f); screenShadowNode.setPadding({0, 0, 0, headerHeight}); From 7f99e01cc493abccb4810d4dcfb99574642bb833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Boro=C5=84?= Date: Wed, 22 Jul 2026 16:16:53 +0200 Subject: [PATCH 2/2] Add repro --- apps/src/tests/issue-tests/Test4357.tsx | 56 +++++++++++++++++++++++++ apps/src/tests/issue-tests/index.ts | 1 + 2 files changed, 57 insertions(+) create mode 100644 apps/src/tests/issue-tests/Test4357.tsx diff --git a/apps/src/tests/issue-tests/Test4357.tsx b/apps/src/tests/issue-tests/Test4357.tsx new file mode 100644 index 0000000000..8741f980fe --- /dev/null +++ b/apps/src/tests/issue-tests/Test4357.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import { Button, StyleSheet, View } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { Colors } from '@apps/shared/styling'; + +const Stack = createNativeStackNavigator(); + +const DummyContent = () => ; + +function Screen1() { + return ; +} + +function NavigationStack() { + return ( + + + + + + ); +} + +export default function App() { + const [show, setShow] = useState(false); + + if (show) { + return ; + } + + return ( + +