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
setVisible(false) too by the way).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;
};
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:
onSuccess: async () => {
showSuccess("Affirmation created successfully!");
await new Promise(r => setTimeout(r, 10));
setVisible(false);
};
<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.