Skip to content

refactor(common-adapters): give each popup mode its own module - #29609

Draft
chrisnojima wants to merge 1 commit into
masterfrom
nojima/HOTPOT-arch-05-popup-modes
Draft

refactor(common-adapters): give each popup mode its own module#29609
chrisnojima wants to merge 1 commit into
masterfrom
nojima/HOTPOT-arch-05-popup-modes

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

PopupProps had 16 fields, and popup/index.tsx dispatched on runtime state into four presentation modes. Most props were dead in any given mode:

Mode Props read Props silently ignored
Portal-only 1 15
PopupCentered 3 13
PopupSheet 5 11
DesktopPopupPositioned 10 6

The mode was selected by which combination you passed, so a caller had to already know the platform to fill the interface in correctly. 81 files render Popup/FloatingMenu; 26 branch on platform within ±15 lines of it. reaction-tooltip.tsx renders two Kb.Popup elements in one component sharing only two props.

The platform rule was encoded three times independently. Changing one default inside Popup previously required editing 13 files to opt out.

Change

Three real modules with narrow interfaces — AnchoredPopup, Sheet, ModalCover — plus Popup kept as a small policy module picking a mode from intent, so the platform default lives in exactly one place and the 40+ menu one-liners are unchanged.

The intent set is 'menu' | 'dialog', derived by classifying all 22 real call sites, not invented. Sites whose behaviour never depended on platform now take a mode directly.

popup/index.tsx: 255 → 45 lines. The file-suffix seam is restored, so @gorhom/bottom-sheet and react-native-screens live only in sheet.native.tsx, and the raw <div> only in modal-cover.desktop.tsx — which also resolves a no-DOM-in-plain-.tsx violation.

Removed as provably dead

visible (see below), mobileAnchored (2 sites → AnchoredPopup), and hideKeyboard's latent trap — it now exists only on the one mode that implements it. usePopup2's isMobile ? undefined : popupAnchor and min-writer-role's ref={isMobile ? null : popupAnchor} are both gone.

One regression caught in review

Removing visible was justified as "every caller passes true, and FloatingMenu returns null first". The second half was wrong: the guard is if (!visible && mode !== 'modal'). For mode="modal" with visible={false}, FloatingMenu fell through and Popup caught it. Deleting that catch while leaving the guard meant that combination would render.

Not reachable today — the only mode="modal" site passes a literal true — but fixed by collapsing the guard to if (!visible) return null, verified equivalent (the modal branch has no exit animation to wait for). Pinned by a test.each over all three modes that fails on exactly the modal case.

Validation

lint:all clean — 0 bailed out, 0 whole-props deps, tsc clean both projects.
jest --runInBand232 suites / 2247 tests (baseline 230 / 2236).

ModalCover gets 8 tests and FloatingMenu 3 — modes that could not be tested at all while they were branches inside a dispatcher importing FullWindowOverlay at module scope.

Popup was one 255-line dispatcher behind a 16-field interface that
branched on runtime state into four unrelated presentations, each
reading a different subset of the props and silently ignoring the rest.
The platform rule was encoded independently in three places.

Split the presentations into three modules with narrow interfaces -
AnchoredPopup, Sheet, ModalCover - and keep Popup as a policy module
that picks one from an explicit intent ('menu' or 'dialog'). The
platform default now lives only there, so usePopup2 no longer gates the
anchor ref and min-writer-role no longer gates its ref either. The type
system rejects placement props on a mode that ignores them, and
hideKeyboard now exists only on the mode that implements it.

Popup no longer takes visible: every caller passed a literal true.
FloatingMenu was the exception - it let mode='modal' past its own
visibility guard and leaned on Popup to drop the hidden menu on the way
past, so that guard is now unconditional.

Sheet and ModalCover get file suffixes, so @gorhom/bottom-sheet and
react-native-screens leave the desktop bundle and the raw div leaves the
native one. ModalCover, previously untestable inside the dispatcher,
gets tests for its press and escape handling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant