fix: stop the cold start flashing white and the mail header jumping in

On a cold start the splash was followed by about a second of blank
white, then mail rows whose header sat under the status bar until it
popped in and pushed the list down.

- `AppTheme` had no `android:windowBackground`, so while JS loaded the
  window showed the platform default (near-white, or grey at night).
  It is now the app's own background, #ffffff by day and #09090b at
  night (`values-night`), matching `src/theme/tokens.ts`.
- `NavigationContainer` had no theme, so React Navigation painted its
  light default behind and between screens even in dark mode. It now
  uses the palette's background.
- The mail list took its top inset from the native `SafeAreaView`,
  which on Fabric only learns the insets a few frames after mounting.
  It now pads with `useSafeAreaInsets()`, which the navigator's
  safe-area provider fills from the initial window metrics on the first
  render; the value is the same, it just arrives in time.
This commit is contained in:
Linus Rath
2026-09-24 19:35:47 +02:00
parent 82ca359df7
commit b9db3764ff
5 changed files with 23 additions and 6 deletions
+9 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
@@ -267,6 +267,13 @@ export default function App() {
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// React Navigation's default theme is light: without this its containers
// paint white behind and between screens, even in dark mode.
const background = useColors().background;
const navigationTheme = React.useMemo(() => {
const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme;
return { ...base, colors: { ...base.colors, background, card: background } };
}, [resolvedScheme, background]);
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
@@ -642,7 +649,7 @@ export default function App() {
}
return (
<NavigationContainer ref={navigationRef}>
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
<StatusBar style={statusBarStyle} />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
@@ -1 +1,4 @@
<resources/>
<resources>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
</resources>
@@ -3,4 +3,6 @@
<color name="iconBackground">#09090b</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
</resources>
@@ -4,6 +4,7 @@
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#09090b</item>
<item name="android:windowBackground">@color/window_background</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
+7 -3
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as DocumentPicker from 'expo-document-picker';
import {
@@ -295,6 +295,10 @@ interface EmailListScreenProps {
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
// The navigator's safe-area provider knows the insets on the first render;
// the native SafeAreaView only learned them a few frames later on Fabric,
// so the header first drew under the status bar and then jumped down.
const insets = useSafeAreaInsets();
const { t } = useLocaleStore();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
@@ -917,7 +921,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [mailboxes, currentMailboxId, selectMailbox]);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
{selectionMode ? (
<View style={styles.header}>
@@ -1592,7 +1596,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
<UndoSnackbar />
</SafeAreaView>
</View>
);
}