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
- If I close the modal using the cross button, there is no problem (Cross button closes the modal with
setVisible(false) too by the way). - There is no such problem when I try it on my Android device and Android simulator (I can't try on an ios device cause I don't have any iPhone, so I have to use xcode simulator for that).
- There is another modal that uses a similar (or even almost identical) system to the one this specific modal uses, and it doesn't cause any problems.
- There are no errors on expo cli console.
What I use
- Hardware: Mac mini m4
- OS: macOS Tahoe 26.1
- Simulator: Xcode simulator iPhone 16e and iPhone 17 pro (both iOS 26.1)
- Xcode version: Version 26.1.1 (17B100)
- The phone I have: Samsung galaxy A23
- Environment: react-native (0.81.4), expo (54.0.12)
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;};