One modal step, five problems
Just because the satisfaction step in our retrospect guide is a modal, five separate problems lined up after it. We eventually had to swap out RN Modal.
One modal step, five problems
Started simple
The retrospect guide flow has three steps: result → satisfaction → retrospect. Result and retrospect are inline text inputs. Satisfaction is a 5-star modal. So a modal sits in the middle of an otherwise inline flow.
The bug report was simple: “After picking satisfaction, focus doesn’t go to the retrospect input.”
Sounded easy. Close the modal, focus the next input. That one line unfolded into five problems.
Problem 1: focus race condition
When the satisfaction modal closed, focusing the retrospect input didn’t always work. iOS first responder transitions were racing with the modal close animation.
First attempt: setTimeout(focus, 100ms). Sometimes it worked, sometimes not.
Then I noticed edit-daily-review does the same kind of pattern cleanly. It uses setTimeout(focus, 300ms) — long enough to let the modal close animation finish before focusing.
Fundamentally, what was needed was a callback that fired when the BottomSheetModal had actually unmounted. I added an onClosed prop that fires after the close animation completes and the underlying RN Modal unmounts. Calling handleGuideNext() there gave us a clean focus chain — no race.
Problem 2: changing satisfaction was too heavy
Focus worked, but now the user said “this is so slow.”
The cause: taskRecordMutations.upsert → bumpVersion → global atom update → BrowseTaskRecordPage (a 2400-line component) re-renders entirely. Every satisfaction change triggered this heavy render path, blocking the modal close → focus chain.
I checked how the other fields (result, retrospect) handled changes. They used local React state — no atom involved, just a lightweight setState. I switched satisfaction to the same pattern.
// Before: heavy
taskRecordMutations.upsert(json); // bumpVersion → global re-render
// After: light
setSatisfaction(value); // local state
setTaskRecordSatisfaction(...).then(...); // API in the background
Same UX pattern, but how it’s implemented changes the perceived speed completely. There was a light path right there — we just hadn’t been using it.
Problem 3: scroll-to-top didn’t reach the top
When the guide ended, we wanted the page to scroll back to the top. But it kept stopping in the middle, or sometimes not even starting.
The trail led to this line: footerHeight = guideMode ? window.height : 300. When guide mode ended, the footer collapsed sharply, the content size shrunk, and the scroll target got clamped.
Fix: extracted a separate keepGuideFooter state. The end-of-guide sequence became:
- t=0: dismiss keyboard, show transition message
- t=300ms: start scroll-to-top (footer still big)
- t=1500ms: fade out the guide bar (LayoutAnimation)
- t=2200ms: shrink the footer (LayoutAnimation)
Scroll completes safely at offset 0, then footer shrinks. Having to space things out by hand is annoying, but it stops simultaneous layout changes from breaking each other.
Problem 4: the guide bar got covered by the modal’s dim
When the satisfaction modal opened, the guide bar at the top of the screen got darkened by the modal’s backdrop dim.
First I added a backdropTopInset prop and offset the backdrop start position below the guide bar. Visually the guide bar was visible — but the buttons didn’t respond.
This is a fundamental limitation of iOS RN <Modal>: Modal renders into a separate UIWindow, and that window intercepts every touch in its bounds. Even if there’s nothing visually rendered in part of the modal, touches in that area still die in the modal window.
I was stuck for a while. The user even asked, “you can’t fix this, can you?” — and I said yes, can’t fix, RN Modal is its own native window.
But I was thinking about it wrong. The goal isn’t to layer something over the modal, it’s to render the modal a different way that doesn’t create a separate window.
Problem 4 fix: drop RN Modal, use Portal
@gorhom/portal was already in the project. Portal renders at the root of the main RN window — no separate native window.
Added a usePortal opt-in prop to BottomSheetModal:
if (usePortal) {
return (
<Portal>
<View style={ABSOLUTE_FILL} pointerEvents={mounted ? "box-none" : "none"}>
{content}
</View>
</Portal>
);
}
return (
<Modal transparent visible={mounted} onRequestClose={onRequestClose}>
{content}
</Modal>
);
The key is pointerEvents="box-none" on the wrapper. Touches in empty areas (where the guide bar lives) pass through to the underlying view naturally. Touches on the backdrop and the sheet still work as expected.
Only the satisfaction modal in guide mode passes usePortal={true}. All other modals keep the default RN Modal behavior. Opt-in, no broad blast radius.
Problem 5: Next while modal was open didn’t close it
With Portal, the guide bar and the modal are visible together, so the user can press the Next button while the modal is up. Pressing Next advanced the guide step — but the modal stayed.
Cause: autoOpen flipping from true to false didn’t trigger a close. The SatisfactionPicker’s effect only handled the open path:
useEffect(() => {
if (autoOpen) {
setShowModal(true);
onOpenChange?.(true);
} else if (showModal) {
// added: when autoOpen flips off, close the modal
setShowModal(false);
onOpenChange?.(false);
}
}, [autoOpen]);
Then in handleGuideNext, when leaving the satisfaction step, set setGuideSatisfactionAutoOpen(false) so the effect closes the modal.
Recap
Just because one modal sits in the middle of a guide flow:
- focus race condition — added an
onClosedcallback - heavy global re-render — switched to local state pattern
- scroll-to-top clamp — separated footer-shrink timing
- guide bar dimmed and untouchable — replaced RN Modal with Portal
- modal stayed when Next was pressed — closed on autoOpen-flip
Five problems, all side effects of one structural fact: a modal step in the middle of an inline flow. Switching to an inline picker would have erased all five at once — but the user firmly said “the inline picker doesn’t exist for us” (with reasons of their own), so we worked through them one by one.
What I took away
Structural decisions vs. workarounds. Some problems disappear once you change the structure; others linger and you have to route around them five times. Both can be the right call, depending on non-functional values — brand identity, user mental model, and so on.
I thought the inline picker was cleaner. The user thought a modal makes the satisfaction step feel intentional and focused. From the developer side, “this looks better to me” isn’t always right.
This is the first time I really felt the limits of RN Modal. I knew “RN Modal is its own window, so you can’t layer above it” — but the thought “then just don’t use RN Modal” took a while to surface. When you hit a fundamental limit of a familiar tool, remember the tool was a choice, not a given.
And one more — when the user says “it’s flaky,” take it seriously. A setTimeout(100ms) working sometimes was a signal of a race condition, but my first instinct was to try a longer timeout. The right answer was an event-based callback (onClosed) — and getting there earlier would have saved a lot of time.