iostypescriptxcodereact-nativeios-simulator

Xcode simulator black screen problem when a specific model was closed - React native expo


Problem
After a successful request, when the modal is closed with setVisible(false), a black screen appears just like that. To explain it more clearly: I open the modal with this button and after filling out the form in the modal, when I press the submit button, a loading popup appears and then the full black screen immediately.

Related part of my code:

const mutation = useMutation<AffirmationRes, AxiosError, AffirmationReq>({
    mutationFn: createAffirmation,
    onSuccess: async () => {
      resetAffirmation("RESET_REQUEST", affirmationCtx);
      setSelectedCategory("All");
      setAffirmationText("");
      setImage("");
      setIsPrivate(true);
      showSuccess("Affirmation created successfully!");
      setVisible(false); // <- I think this cause the problem
    },
    onError: (err) => {
      handleErrors(err, affirmationCtx);
    },
  });

  const handlePress = () => {
    const payload: AffirmationReqMode = {
      mode: "manuel",
      data: {
        category: selectedCategory,
        image: image,
        isPublic: !isPrivate,
        tags: [],
        text: affirmationText,
      },
    };

    const req = handleAffirmationReq(payload, affirmationCtx);

    if (!req) {
      return;
    }

    mutation.mutate(req);
  };

If I remove setVisible(false) then no more black screen but why?

Quirks

What I use

Full of my code

NewAffirmationForm.tsx (the problematic one)

import { ThinLoadingIcon } from "@/components/icons";
import { AppPopup } from "@/components/overlays";
import {
  AppButton,
  AppHeader,
  AppPhotoInput,
  AppPicker,
  AppTextArea,
  CloseButton,
  ToggleCard,
} from "@/components/ui";
import { popupContent } from "@/content";
import { spacing } from "@/design-tokens";
import { AppTheme } from "@/design-tokens/colors";
import usePremium from "@/features/payment/hooks/usePremium";
import useImage from "@/hooks/useImage";
import useStyles from "@/hooks/useStyles";
import {
  Affirmation,
  AffirmationCategory,
  AffirmationReq,
  AffirmationReqMode,
  AffirmationRes,
} from "@/types";
import { showSuccess } from "@/utils/toast";
import { FlashListRef } from "@shopify/flash-list";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { router } from "expo-router";
import { RefObject, useState } from "react";
import { Modal, ScrollView, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createAffirmation } from "../api";
import { categories } from "../content";
import { useAffirmation } from "../hooks";
import { handleErrors } from "../utils/api";
import { resetAffirmation, setAffirmationPopup } from "../utils/dispatch";
import { handleAffirmationReq } from "../utils/query";
import AffirmationPopup from "./AffirmationPopup";

type props = {
  visible: boolean;
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
  affirmationsFeedRef: RefObject<FlashListRef<Affirmation> | null>;
  onClose: () => void;
};

const NewAffirmationForm = ({ visible, setVisible, onClose }: props) => {
  const [selectedCategory, setSelectedCategory] =
    useState<AffirmationCategory>("All");
  const [affirmationText, setAffirmationText] = useState<string>("");
  const [image, setImage] = useState<string>("");
  const [isPrivate, setIsPrivate] = useState<boolean>(true);

  const insets = useSafeAreaInsets();
  const { pickImage } = useImage();
  const { styles, theme } = useStyles(makeStyles);
  const affirmationCtx = useAffirmation();
  const premium = usePremium();

  const mutation = useMutation<AffirmationRes, AxiosError, AffirmationReq>({
    mutationFn: createAffirmation,
    onSuccess: async () => {
      resetAffirmation("RESET_REQUEST", affirmationCtx);
      setSelectedCategory("All");
      setAffirmationText("");
      setImage("");
      setIsPrivate(true);
      showSuccess("Affirmation created successfully!");
      setVisible(false);
    },
    onError: (err) => {
      handleErrors(err, affirmationCtx);
    },
  });

  const handlePress = () => {
    const payload: AffirmationReqMode = {
      mode: "manuel",
      data: {
        category: selectedCategory,
        image: image,
        isPublic: !isPrivate,
        tags: [],
        text: affirmationText,
      },
    };

    const req = handleAffirmationReq(payload, affirmationCtx);

    if (!req) {
      return;
    }

    mutation.mutate(req);
  };

  const handlePressUploadImage = () => {
    const isPremium = premium.data?.data.subscription.isPremium;

    if (!isPremium) {
      setAffirmationPopup(affirmationCtx, popupContent.payment.photo, () => {
        setVisible(false);
        router.push("/paywall");
      });
      return;
    }

    pickImage(setImage);
  };

  return (
    <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
      <View
        style={[
          styles.contentContainer,
          { paddingTop: insets.top, paddingBottom: insets.bottom },
        ]}
      >
        <ScrollView
          contentContainerStyle={styles.container}
          keyboardShouldPersistTaps="handled"
          showsVerticalScrollIndicator={false}
        >
          <AppHeader
            title="New Affirmation"
            icon={<CloseButton onPress={onClose} />}
            side="right"
          />
          <AppPicker
            label="Pick a category"
            value={selectedCategory}
            setValue={setSelectedCategory}
            data={categories}
            icon="product"
          />
          <AppTextArea
            value={affirmationText}
            setValue={setAffirmationText}
            maxLength={100}
            showLabel
            label="Enter your affirmation"
            placeholder="I am a peaceful sanctuary..."
            showCharacterCount
          />
          <AppPhotoInput
            setImage={setImage}
            image={image}
            label="Photo (optional)"
            showLabel
            onUploadPress={handlePressUploadImage}
            disabled={premium.isFetching}
          />
          <ToggleCard
            selected={isPrivate}
            setSelected={setIsPrivate}
            label="Make private"
          />
          <AppButton onPress={handlePress}>
            {mutation.isPending ? (
              <ThinLoadingIcon color={theme.primary} width={25} height={25} />
            ) : (
              "Create"
            )}
          </AppButton>
        </ScrollView>
      </View>
      <AffirmationPopup />
      {mutation.isPending && <AppPopup isVisible status="loading" />}
    </Modal>
  );
};

const makeStyles = (theme: AppTheme) =>
  StyleSheet.create({
    container: {
      backgroundColor: theme.background,
      paddingHorizontal: spacing["s-4"],
      paddingBottom: spacing["s-4"],
      gap: spacing["s-4"],
      justifyContent: "space-between",
    },
    contentContainer: {
      gap: spacing["s-4"],
      flex: 1,
      backgroundColor: theme.background,
    },
  });

export default NewAffirmationForm;

NormalEntry.tsx (the another similar modal (I mentioned) but not causing black screen like above one.)

import { ThinLoadingIcon } from "@/components/icons";
import { AppButton, AppHeader, CloseButton } from "@/components/ui";
import { DayStreak } from "@/components/ui/DayStreak";
import { popupContent } from "@/content";
import { spacing } from "@/design-tokens";
import useTheme from "@/hooks/useTheme";
import { CreateEntryRes, EntryReq, GratitudeEntry } from "@/types";
import { getClientTimeZone } from "@/utils/date";
import { buildDeviceString } from "@/utils/metaData";
import { getTagsFromString } from "@/utils/string";
import { showSuccess } from "@/utils/toast";
import { FlashListRef } from "@shopify/flash-list";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { RefObject } from "react";
import { Modal, ScrollView, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createEntry } from "../api";
import { TodaysAffirmation } from "../components";
import { useEntry } from "../hooks";
import {
  AddEntrySection,
  EntryEmoteSection,
  EntryPhotoSection,
  EntryToggleSection,
} from "../sections";
import { handleErrors } from "../utils/api";
import {
  resetEntry,
  setEntryError,
  setEntryPopup,
  setEntryStatus,
} from "../utils/dispatch";
import EntryPopup from "./EntryPopup";

const NormalEntry = ({
  visible,
  onClose,
  setVisible,
  entriesFeedRef,
}: {
  visible: boolean;
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
  entriesFeedRef: RefObject<FlashListRef<GratitudeEntry> | null>;
  onClose: () => void;
}) => {
  const insets = useSafeAreaInsets();
  const { theme } = useTheme();
  const entryCtx = useEntry();
  const mutation = useMutation<CreateEntryRes, AxiosError, EntryReq>({
    mutationFn: createEntry,
    onSuccess: (res) => {
      showSuccess("Entry created successfully!");
      resetEntry("RESET_REQUEST", entryCtx);

      if (res) {
        if (!res.data) return;

        setVisible(false);
        entriesFeedRef.current?.scrollToIndex({ index: 0, animated: true });
      }
    },
    onError: (err) => {
      handleErrors(err, entryCtx);
    },
  });

  const handlePress = async () => {
    const ctxReq = entryCtx.state.values.request;

    const req: EntryReq = {
      gratefulFor: ctxReq.gratefulFor,
      mood: ctxReq.mood,
      photo: ctxReq.photo,
      isPrivate: ctxReq.isPrivate,
      tags: getTagsFromString(ctxReq.gratefulFor.join(", ")),
      metaData: {
        deviceInfo: buildDeviceString(),
        timeZone: getClientTimeZone(),
      },
    };

    if (!Array.isArray(req.gratefulFor) || req.gratefulFor.length === 0) {
      setEntryError(entryCtx, {
        ...entryCtx.state.errors,
        gratefulFor: ["add at least one"],
      });

      setEntryPopup(entryCtx, popupContent.createEntry.missingText);
      setEntryStatus(entryCtx, "fail");

      return;
    }

    if (!req.mood) {
      setEntryError(entryCtx, {
        ...entryCtx.state.errors,
        mood: ["please select a mood"],
      });

      setEntryPopup(entryCtx, popupContent.createEntry.missingEmote);
      setEntryStatus(entryCtx, "fail");

      return;
    }

    mutation.mutate(req);
  };

  return (
    <Modal
      transparent
      visible={visible}
      animationType="slide"
      onRequestClose={onClose}
    >
      <GestureHandlerRootView
        style={{ flex: 1, backgroundColor: theme.foreground }}
      >
        <View
          style={{
            flex: 1,
            backgroundColor: theme.background,
            paddingTop: insets.top,
            paddingBottom: insets.bottom,
          }}
        >
          <ScrollView
            contentContainerStyle={{
              paddingHorizontal: spacing["s-4"],
              paddingBottom: spacing["s-4"],
              gap: spacing["s-4"],
            }}
            keyboardShouldPersistTaps="handled"
            showsVerticalScrollIndicator={false}
          >
            <View>
              <AppHeader
                title={new Date().toLocaleDateString("en-EU")}
                icon={<CloseButton onPress={onClose} />}
                side="right"
              />

              <DayStreak />

              <View style={{ gap: spacing["s-6"] }}>
                <AddEntrySection />
                <EntryEmoteSection />
                <EntryPhotoSection setVisible={setVisible} />
                <EntryToggleSection />
                <TodaysAffirmation />
              </View>
            </View>

            <AppButton onPress={handlePress}>
              {mutation.isPending ? (
                <ThinLoadingIcon color={theme.primary} width={25} height={25} />
              ) : (
                "Sent"
              )}
            </AppButton>
          </ScrollView>
        </View>
      </GestureHandlerRootView>
      <EntryPopup />
    </Modal>
  );
};

export default NormalEntry;

AffirmationScreen.tsx (Where I called the NewAffirmationForm component. Parent of that.)

import { ThinLoadingIcon } from "@/components/icons";
import { AppPopup } from "@/components/overlays";
import { AppHeader, AppText } from "@/components/ui";
import { AFFIRMATIONS_FEED_QUERY_KEY } from "@/constants";
import { spacing } from "@/design-tokens";
import { AppTheme } from "@/design-tokens/colors";
import { fetchAffirmations } from "@/features/affirmation/api";
import { AffirmationCard } from "@/features/affirmation/components";
import { useAffirmation } from "@/features/affirmation/hooks";
import {
  AffirmationPopup,
  NewAffirmationForm,
} from "@/features/affirmation/overlay";
import {
  AffirmationCollection,
  AffirmationSlider,
  EntryUnlockSection,
} from "@/features/affirmation/sections";
import {
  closeAffirmationPopup,
  resetAffirmation,
} from "@/features/affirmation/utils/dispatch";
import usePremium from "@/features/payment/hooks/usePremium";
import useStyles from "@/hooks/useStyles";
import useTheme from "@/hooks/useTheme";
import { Affirmation, AffirmationCategory, AffirmationsRes } from "@/types";
import { FlashList, FlashListRef } from "@shopify/flash-list";
import { InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useMemo, useRef, useState } from "react";
import {
  RefreshControl,
  StyleSheet,
  TouchableOpacity,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

const AffirmationScreen = () => {
  const [showNewAffirmationForm, setShowNewAffirmationForm] =
    useState<boolean>(false);
  const [selectedFilter, setSelectedFilter] =
    useState<AffirmationCategory>("All");

  const { styles } = useStyles(makeStyles);
  const { theme } = useTheme();
  const affirmationCtx = useAffirmation();
  const premium = usePremium();

  const affirmationsFeedRef = useRef<FlashListRef<Affirmation>>(null);

  const isPremium = premium.data?.data.subscription.isPremium;
  const PAGE_SIZE = isPremium ? 5 : 3;

  const {
    data,
    error,
    isLoading,
    isRefetching,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    refetch,
  } = useInfiniteQuery<
    AffirmationsRes,
    AxiosError,
    InfiniteData<AffirmationsRes, number>,
    [typeof AFFIRMATIONS_FEED_QUERY_KEY, AffirmationCategory],
    number
  >({
    queryKey: [AFFIRMATIONS_FEED_QUERY_KEY, selectedFilter],
    initialPageParam: 1,
    queryFn: ({ pageParam, queryKey, signal }) => {
      const [, filter] = queryKey;
      return fetchAffirmations({
        params: { page: pageParam, limit: PAGE_SIZE, category: filter },
        opts: { signal },
      });
    },
    getNextPageParam: (lastPage, _allPages, lastPageParam) => {
      if (!isPremium) return;
      const pg = lastPage.data.pagination;
      return pg.hasNext ? lastPageParam + 1 : undefined;
    },
  });

  const affirmations: Affirmation[] = useMemo(() => {
    return data?.pages.flatMap((p) => p.data.affirmations) ?? [];
  }, [data]);

  if (isLoading) {
    return (
      <View style={{ padding: spacing["s-6"], alignItems: "center", flex: 1 }}>
        <ThinLoadingIcon color={theme.primary} width={40} height={40} />
      </View>
    );
  }

  if (error) {
    return (
      <View style={{ padding: spacing["s-6"], flex: 1 }}>
        <AppText>Something went wrong: {error.message}</AppText>
        <TouchableOpacity onPress={() => refetch()}>
          <AppText>Refetch (temp)</AppText>
        </TouchableOpacity>
      </View>
    );
  }

  const handleClose = () => {
    setShowNewAffirmationForm(false);
    resetAffirmation("RESET_REQUEST", affirmationCtx);
    closeAffirmationPopup(affirmationCtx);
  };

  return (
    <SafeAreaView style={styles.container} edges={["left", "right", "top"]}>
      <FlashList
        ref={affirmationsFeedRef}
        style={styles.list}
        data={affirmations}
        keyExtractor={(item) => item?._id}
        renderItem={({ item }) => (
          <AffirmationCard
            text={item?.text.replace(/['"]+/g, "")}
            image={{ uri: item.image }}
          />
        )}
        ListHeaderComponent={
          <>
            <AppHeader title="Affirmations" />
            <AffirmationSlider />
            <AffirmationCollection
              selectedFilter={selectedFilter}
              setSelectedFilter={setSelectedFilter}
              setShowNewAffirmationForm={setShowNewAffirmationForm}
            />
          </>
        }
        refreshControl={
          <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
        }
        onEndReached={() => {
          if (hasNextPage && !isFetchingNextPage) fetchNextPage();
        }}
        ListFooterComponent={
          <>
            {!premium.data?.data.subscription.isPremium && (
              <EntryUnlockSection />
            )}
            {isFetchingNextPage && (
              <ThinLoadingIcon
                color={theme.primary}
                width={25}
                height={25}
                style={{ marginBottom: spacing["s-3"] }}
              />
            )}
          </>
        }
        onEndReachedThreshold={undefined}
        showsVerticalScrollIndicator={false}
      />
      {showNewAffirmationForm && (
        <View style={{ flex: 1, backgroundColor: theme.foreground }}>
          <NewAffirmationForm
            visible={showNewAffirmationForm}
            setVisible={setShowNewAffirmationForm}
            affirmationsFeedRef={affirmationsFeedRef}
            onClose={handleClose}
          />
        </View>
      )}
      {(isLoading || premium.isFetching) && (
        <AppPopup isVisible status="loading" />
      )}
      <AffirmationPopup />
    </SafeAreaView>
  );
};

const makeStyles = (theme: AppTheme) =>
  StyleSheet.create({
    container: {
      flex: 1,
      backgroundColor: theme.background,
      paddingHorizontal: spacing["s-4"],
    },
    list: {
      flex: 1,
      alignSelf: "stretch",
    },
  });

export default AffirmationScreen;

handleAffirmationReq function

const handleAffirmationReq = (
  payload: AffirmationReqMode,
  affirmationCtx: AffirmationCtx
): AffirmationReq | undefined => {
  let req: AffirmationReq = payload.data;
  if (payload.mode === "ai") {
    console.log("ai"); // temp
    const aiData = payload.data as AffirmationReqWithAI;
    const aiPrompt = aiData?.aiPrompt?.trim() ?? "";

    if (aiPrompt.length < 10) {
      setAffirmationPopup(affirmationCtx, popupContent.affirmation.aiPrompt);
      return;
    }

    req = {
      category: aiData.category,
      image: aiData.image,
      isPublic: aiData.isPublic,
      tags: getTagsFromString(aiPrompt),
      generateWithAI: true,
      aiPrompt,
    };
  } else if (payload.mode === "manuel") {
    console.log("manuel"); // temp
    const manualData = payload.data as AffirmationReqWithoutAI;
    const text = manualData?.text?.trim() ?? "";

    if (text.length < 10) {
      setAffirmationPopup(
        affirmationCtx,
        popupContent.affirmation.textTooShort
      );
      return;
    }

    req = {
      category: manualData.category,
      image: manualData.image,
      isPublic: manualData.isPublic,
      tags: getTagsFromString(text),
      text,
    };
  }

  if (!req) setAffirmationPopup(affirmationCtx, popupContent.undefinedData);

  return req;
};

Solution

  • When I closed my modal with setVisible(false) inside the mutation’s onSuccess callback, the iOS simulator consistently showed a black screen. The same code worked normally on Android, and closing the modal with the close button never caused the issue. After isolating the problem, it turned out that the modal itself was not the real cause.

    On iOS, a Modal creates its own native window layer. If the modal is unmounted at the same time another modal-like overlay is still being rendered, iOS briefly loses the background layer and renders a black screen. In my case, a loading popup was still visible for one render cycle while the modal was closing:

    {mutation.isPending && <AppPopup isVisible status="loading" />}
    

    The mutation completes, the modal closes immediately, but the loading popup remains mounted for a single frame. That overlap is enough to produce the black screen on iOS.

    There are two practical fixes:

    1. Delay closing the modal very slightly so the loading popup has time to unmount before the modal disappears.
    onSuccess: async () => {
      showSuccess("Affirmation created successfully!");
      await new Promise(r => setTimeout(r, 10));
      setVisible(false);
    };
    
    1. A better structural fix is to avoid rendering popups or modal-like overlays inside a <Modal /> component. Moving the popup to a parent component prevents iOS from stacking modal windows on top of each other.
    // Render this outside of the modal
    <AppPopup isVisible={mutation.isPending} />
    

    After either applying a short delay or moving the popup outside the modal, the black screen issue disappeared completely on iOS.