The vanishing sheet, the captured screen — chasing an iOS Modal stacking bug
Completing a template-based task left the screen frozen right after the satisfaction sheet vanished. Animations kept playing, but every tap was eaten. A debugging trail through several wrong guesses before finding the real cause.
The vanishing sheet, the captured screen
The symptom was simple. Completing a template-based task showed the satisfaction sheet briefly, then the moment it disappeared the entire screen stopped accepting touches. Star animations and other motion kept playing, but tapping anywhere did nothing.
Tasks without a template completed without any freeze. The signal was clear, but I spent a while not understanding why template tasks specifically.
The wrong guesses
The morning’s commits looked suspicious. Visual changes had landed for the new “in-progress” indicator, and there’d been some adjacent tweaks to the satisfaction sheet path. Easy to suspect.
My first hypothesis: BottomSheetModal’s mounted state isn’t releasing. If the close animation callback fails to fire for some reason, setMounted(false) never runs and the native Modal stays alive, intercepting touches.
I added a safety net — a setTimeout(closeDuration + 200) to force unmount if the callback was late. One test it seemed to release; the next it froze again. Not the fix.
Second hypothesis: make BottomSheetModal lifecycle more deterministic. Drop the dependency on the animation callback, use setTimeout for mount control instead. No effect. The user said “still stuck.”
That’s when I realized I was looking in the wrong place. BottomSheetModal was closing cleanly. Something underneath it was the problem.
The deciding question
To narrow down, I added logs across all the close-time components — BottomSheetModal lifecycle, AchievementOverlay render, ResultReviewCard render. The trace came back:
[AchievementOverlay] render, queue.length=2 head=achievement
[BottomSheetModal] effect run, isVisible=true mounted=false
[BottomSheetModal] anim cb finished=true isVisible=true
[BottomSheetModal] effect run, isVisible=false mounted=true
[BottomSheetModal] anim cb finished=true isVisible=false
[BottomSheetModal] cb setMounted(false)
[BottomSheetModal] effect run, isVisible=false mounted=false
[AchievementOverlay] render, queue.length=1 head=result_review
[ResultReviewCard] render, taskId=... contentReady=true
BottomSheetModal reached mounted=false cleanly. But the screen was still frozen. I’d been temporarily painting a red translucent backdrop in ResultReviewCard to make it visible, and the user reported “I don’t see any red.”
That was the deciding clue — the sheet had visually disappeared, but AchievementOverlay’s content was not being drawn where it should have been.
The real cause
AchievementOverlay was its own RN Modal — <Modal transparent visible animationType="none">. Inside it: either a ResultReviewCard or an AchievementCard, depending on the queue. Template tasks push a result_review event onto the queue, which opens AchievementOverlay. Non-template tasks never push that event, so AchievementOverlay’s Modal never opens for them.
The sequence was:
- AchievementOverlay Modal opens (queue updated)
- RetrospectSatisfactionModal (BottomSheetModal) opens on top
- User dismisses → BottomSheetModal closes
- In theory, AchievementOverlay returns to the foreground
- In reality, AchievementOverlay’s native layer stays in an invisible-but-touch-capturing state
iOS, when two transparent <Modal>s are presented natively in sequence and the upper one dismisses, sometimes leaves the lower one with its touch capture intact but its visual layer un-restored. The React tree still has the content; the screen doesn’t show it; and every touch in that native frame’s coordinates gets swallowed at the native level.
The symptoms lined up exactly:
- Content not visible → invisible
- All touches swallowed → touch-capturing
- Animations kept running → other native-driver animations unaffected (Reanimated, CALayer)
- Template tasks only → result_review is template-only, so AchievementOverlay only opens then
Routing around it with Portal
The fix was not using a native Modal for the secondary overlay. @gorhom/portal’s <Portal> hoists content to a PortalHost inside the React tree. No native Modal stack involvement; no iOS stacking bug.
AchievementOverlay swapped <Modal> for <Portal>, and in _layout.tsx it moved inside PortalProvider. BottomSheetModal kept its RN <Modal> (it’s the primary interaction sheet — native layer is natural there). The Portal-based AchievementOverlay floats above it as needed, no stacking effects, and remains cleanly visible after BottomSheetModal closes.
One more touch — with both visible at once during the close, the brief overlap looked awkward. I added a satisfactionSheetActiveAtom and gated AchievementOverlay to hold rendering while that atom is true. Users see satisfaction sheet → dismiss → result card in clean sequence.
Looking back
The most time-consuming part was looking in the wrong place. I prodded BottomSheetModal’s mount lifecycle two or three times and even added a safety net — meanwhile the real cause was the existence of another Modal underneath, which I hadn’t even noticed was involved.
The defensive code I added (the safety net, the ResultReviewCard empty-content fallback) wasn’t wrong on its own, but it was patching over the symptom. The real fix was a single architectural choice — Portal instead of Modal — and everything else came off.
The lesson is small but worth keeping: the component closest to the symptom isn’t always the cause. The satisfaction sheet was the location of the freeze, but the cause was a different invisible Modal beneath it. Asking “what else is open right now, and what state is it in at the native level?” would have shortened this by hours.
And the rule went into memory — don’t stack two transparent RN Modals on iOS; route secondary overlays through Portal. Next time I’ll spend less time on the wrong layer.