diff --git a/apps/mobile/__tests__/components/habits/habit-list.test.tsx b/apps/mobile/__tests__/components/habits/habit-list.test.tsx index 3f936d5e9..d4a5f6c51 100644 --- a/apps/mobile/__tests__/components/habits/habit-list.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-list.test.tsx @@ -173,6 +173,15 @@ vi.mock('@/lib/theme', async (importOriginal) => { vi.mock('@/components/ui/anchored-menu', () => ({ AnchoredMenu: ({ visible, children }: any) => (visible ? children : null), + MenuAnchorHost: ({ children }: any) => children, + useAnchoredMenu: () => ({ + anchorRef: { current: null }, + visible: false, + anchorRect: null, + open: () => {}, + close: () => {}, + toggle: () => {}, + }), })) vi.mock('react-native-svg', () => ({ diff --git a/apps/mobile/__tests__/components/habits/habit-row.test.tsx b/apps/mobile/__tests__/components/habits/habit-row.test.tsx index 7906a5b45..f4f893f5b 100644 --- a/apps/mobile/__tests__/components/habits/habit-row.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-row.test.tsx @@ -1,6 +1,11 @@ -import { describe, it, expect, vi } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' import { createMockHabit } from '@orbit/shared/__tests__/factories' import { HabitRow } from '@/components/habits/habit-row' +import { + __resetTestHostConfig, + __setHostRefsNull, + __setMeasureInWindowImpl, +} from '@/test-mocks/react-native' const TestRenderer = require('react-test-renderer') @@ -18,6 +23,14 @@ vi.mock('@/lib/use-app-theme', () => ({ vi.mock('@/lib/motion', () => ({ usePrefersReducedMotion: () => true, + useResolvedMotionPreset: () => ({ + enterDuration: 0, + exitDuration: 0, + scaleFrom: 0.96, + scaleTo: 1, + shift: 8, + }), + toAnimatedEasing: (value: unknown) => value, })) function collectStrings(node: unknown): string[] { @@ -69,3 +82,50 @@ describe('HabitRow tags (mobile)', () => { expect(texts.join('')).toContain('+7') }) }) + +function renderRowWithMenu() { + let renderer: ReturnType + TestRenderer.act(() => { + renderer = TestRenderer.create( + , + ) + }) + return renderer! +} + +function pressMoreButton(renderer: ReturnType) { + const moreButton = renderer.root.findAll( + (node: { props: Record }) => + node.props.accessibilityLabel === 'habits.actions.more', + )[0] + TestRenderer.act(() => { + ;(moreButton.props.onPress as () => void)() + }) +} + +describe('HabitRow menu (mobile)', () => { + afterEach(() => { + __resetTestHostConfig() + }) + + it('opens the menu even when measureInWindow never invokes its callback', () => { + __setMeasureInWindowImpl(() => {}) + const renderer = renderRowWithMenu() + + pressMoreButton(renderer) + + expect(collectStrings(renderer.toJSON())).toContain('common.edit') + }) + + it('opens the menu even when the anchor ref is null', () => { + __setHostRefsNull(true) + const renderer = renderRowWithMenu() + + pressMoreButton(renderer) + + expect(collectStrings(renderer.toJSON())).toContain('common.edit') + }) +}) diff --git a/apps/mobile/__tests__/lib/anchored-menu.test.ts b/apps/mobile/__tests__/lib/anchored-menu.test.ts index 678093e8e..3aca1f6ca 100644 --- a/apps/mobile/__tests__/lib/anchored-menu.test.ts +++ b/apps/mobile/__tests__/lib/anchored-menu.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_ANCHORED_MENU_MARGIN, + FALLBACK_ANCHOR_TOP_INSET, getAnchoredMenuPosition, + getFallbackAnchorRect, } from '@/lib/anchored-menu' describe('anchored menu positioning', () => { @@ -49,3 +51,31 @@ describe('anchored menu positioning', () => { expect(position.opensUp).toBe(true) }) }) + +describe('anchored menu fallback anchor', () => { + it('places a zero-size anchor at the top-right inset', () => { + expect(getFallbackAnchorRect(412)).toEqual({ + x: 412 - DEFAULT_ANCHORED_MENU_MARGIN, + y: FALLBACK_ANCHOR_TOP_INSET, + width: 0, + height: 0, + }) + }) + + it('positions a menu against the top-right corner when no rect was measured', () => { + const window = { width: 412, height: 892 } + const position = getAnchoredMenuPosition({ + anchorRect: getFallbackAnchorRect(window.width), + viewportWidth: window.width, + viewportHeight: window.height, + menuWidth: 208, + menuHeight: 296, + }) + + expect(position.left).toBe( + window.width - 208 - DEFAULT_ANCHORED_MENU_MARGIN, + ) + expect(position.top).toBe(FALLBACK_ANCHOR_TOP_INSET + DEFAULT_ANCHORED_MENU_MARGIN) + expect(position.opensUp).toBe(false) + }) +}) diff --git a/apps/mobile/__tests__/screens/today-screen.test.tsx b/apps/mobile/__tests__/screens/today-screen.test.tsx index 998947eb9..c7462f98f 100644 --- a/apps/mobile/__tests__/screens/today-screen.test.tsx +++ b/apps/mobile/__tests__/screens/today-screen.test.tsx @@ -299,6 +299,15 @@ vi.mock("@/components/navigation/notification-bell", () => ({ vi.mock("@/components/ui/anchored-menu", () => ({ AnchoredMenu: () => null, + MenuAnchorHost: ({ children }: { children?: unknown }) => children, + useAnchoredMenu: () => ({ + anchorRef: { current: null }, + visible: false, + anchorRect: null, + open: () => {}, + close: () => {}, + toggle: () => {}, + }), })); vi.mock("../../app/(tabs)/today-shell", () => todayShellMock); diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 53c7a75ae..041bc9190 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -59,7 +59,7 @@ import { ReferralCard } from "@/components/referral/referral-card"; import { ReferralDrawer } from "@/components/referral/referral-drawer"; import { SetupChecklistCard } from "@/components/today/setup-checklist-card"; import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; -import type { MenuAnchorRect } from "@/lib/anchored-menu"; +import { useAnchoredMenu } from "@/components/ui/anchored-menu"; import { useBulkActions } from "@/hooks/use-bulk-actions"; import { shouldResetSelectionForViewChange } from "@/lib/habit-selection-state"; import { @@ -155,12 +155,20 @@ export default function TodayScreen() { const currentActiveView = resolveTodayView(activeView, hasProAccess); const [showGeneralOnToday, setShowGeneralOnToday] = useState(false); - const [showControlsMenu, setShowControlsMenu] = useState(false); - const [controlsMenuAnchorRect, setControlsMenuAnchorRect] = - useState(null); - const [showFreqMenu, setShowFreqMenu] = useState(false); - const [freqMenuAnchorRect, setFreqMenuAnchorRect] = - useState(null); + const { + anchorRef: controlsButtonRef, + visible: showControlsMenu, + anchorRect: controlsMenuAnchorRect, + close: closeControlsMenu, + toggle: toggleControlsMenu, + } = useAnchoredMenu(); + const { + anchorRef: freqMenuButtonRef, + visible: showFreqMenu, + anchorRect: freqMenuAnchorRect, + close: closeFreqMenu, + toggle: toggleFreqMenu, + } = useAnchoredMenu(); const [showHabitDeleteConfirm, setShowHabitDeleteConfirm] = useState(false); const [slideDirection, setSlideDirection] = useState<"left" | "right">( "right", @@ -182,8 +190,6 @@ export default function TodayScreen() { "/", goalsScrollTo, ); - const controlsButtonRef = useRef(null); - const freqMenuButtonRef = useRef(null); const previousActiveViewRef = useRef(activeView); const dateLabelAnim = useMemo(() => new Animated.Value(0), []); const filtersTransitionAnim = useMemo(() => new Animated.Value(1), []); @@ -693,9 +699,9 @@ export default function TodayScreen() { } previousActiveViewRef.current = activeView; - setShowControlsMenu(false); + closeControlsMenu(); if (isSelectMode) clearSelection(); - }, [activeView, clearSelection, isSelectMode]); + }, [activeView, clearSelection, closeControlsMenu, isSelectMode]); const handleToggleSelectMode = useCallback(() => { if (isSelectMode) { @@ -703,8 +709,8 @@ export default function TodayScreen() { } else { toggleSelectMode(); } - setShowControlsMenu(false); - }, [clearSelection, isSelectMode, toggleSelectMode]); + closeControlsMenu(); + }, [clearSelection, closeControlsMenu, isSelectMode, toggleSelectMode]); const handleToggleCollapse = useCallback(() => { if (habitListRef.current?.allCollapsed) { @@ -712,57 +718,25 @@ export default function TodayScreen() { } else { habitListRef.current?.collapseAll(); } - setShowControlsMenu(false); - }, []); + closeControlsMenu(); + }, [closeControlsMenu]); const handleRefresh = useCallback(() => { habitListRef.current?.refetch(); - setShowControlsMenu(false); - }, []); + closeControlsMenu(); + }, [closeControlsMenu]); const handleToggleCompleted = useCallback(() => { setShowCompleted(!showCompleted); - setShowControlsMenu(false); - }, [setShowCompleted, showCompleted]); - - const measureControlsButton = useCallback(() => { - controlsButtonRef.current?.measureInWindow((x, y, width, height) => { - setControlsMenuAnchorRect({ x, y, width, height }); - setShowControlsMenu(true); - }); - }, []); - - const handleToggleControlsMenu = useCallback(() => { - if (showControlsMenu) { - setShowControlsMenu(false); - return; - } - - measureControlsButton(); - }, [measureControlsButton, showControlsMenu]); - - const measureFreqMenuButton = useCallback(() => { - freqMenuButtonRef.current?.measureInWindow((x, y, width, height) => { - setFreqMenuAnchorRect({ x, y, width, height }); - setShowFreqMenu(true); - }); - }, []); - - const handleToggleFreqMenu = useCallback(() => { - if (showFreqMenu) { - setShowFreqMenu(false); - return; - } - - measureFreqMenuButton(); - }, [measureFreqMenuButton, showFreqMenu]); + closeControlsMenu(); + }, [closeControlsMenu, setShowCompleted, showCompleted]); const handleSelectFrequency = useCallback( (key: FreqKey | null) => { setSelectedFrequency(key); - setShowFreqMenu(false); + closeFreqMenu(); }, - [setSelectedFrequency], + [closeFreqMenu, setSelectedFrequency], ); const handleSelectAll = useCallback(() => { @@ -824,8 +798,8 @@ export default function TodayScreen() { }, []); const handleListScrollBeginDrag = useCallback(() => { - setShowControlsMenu(false); - }, []); + closeControlsMenu(); + }, [closeControlsMenu]); const handleToggleSearch = useCallback(() => { setIsSearchOpen((open) => { @@ -935,10 +909,10 @@ export default function TodayScreen() { onSearchChange={setSearchQueryStore} onSearchFocusChange={setIsSearchFocused} onTagToggle={toggleTagFilter} - onToggleFreqMenu={handleToggleFreqMenu} - onToggleControlsMenu={handleToggleControlsMenu} - onCloseControlsMenu={() => setShowControlsMenu(false)} - onCloseFreqMenu={() => setShowFreqMenu(false)} + onToggleFreqMenu={toggleFreqMenu} + onToggleControlsMenu={toggleControlsMenu} + onCloseControlsMenu={closeControlsMenu} + onCloseFreqMenu={closeFreqMenu} onToggleSelect={handleToggleSelectMode} onToggleCollapse={handleToggleCollapse} onRefresh={handleRefresh} @@ -966,8 +940,8 @@ export default function TodayScreen() { handleSelectFrequency, handleToggleCollapse, handleToggleCompleted, - handleToggleControlsMenu, - handleToggleFreqMenu, + toggleControlsMenu, + toggleFreqMenu, handleToggleSearch, handleToggleSelectMode, isSearchFocused, @@ -979,8 +953,8 @@ export default function TodayScreen() { selectedTagIds, setIsSearchFocused, setSearchQueryStore, - setShowControlsMenu, - setShowFreqMenu, + closeControlsMenu, + closeFreqMenu, sharedHeader, showCompleted, showControlsMenu, diff --git a/apps/mobile/components/goals/goals-view.tsx b/apps/mobile/components/goals/goals-view.tsx index 7e265310a..b5a9e7f54 100644 --- a/apps/mobile/components/goals/goals-view.tsx +++ b/apps/mobile/components/goals/goals-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; import { type FlatList, type NativeScrollEvent, @@ -15,10 +15,13 @@ import { useTranslation } from "react-i18next"; import type { Goal, GoalStatus } from "@orbit/shared/types/goal"; import { useGoals } from "@/hooks/use-goals"; import { GoalList } from "./goal-list"; -import { AnchoredMenu } from "@/components/ui/anchored-menu"; +import { + AnchoredMenu, + MenuAnchorHost, + useAnchoredMenu, +} from "@/components/ui/anchored-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { SectionLabel } from "@/components/ui/section-label"; -import type { MenuAnchorRect } from "@/lib/anchored-menu"; import { createTokensV2 } from "@/lib/theme"; import { useAppTheme } from "@/lib/use-app-theme"; @@ -67,10 +70,13 @@ export function GoalsView({ const styles = useMemo(() => createStyles(tokens), [tokens]); const [activeFilter, setActiveFilter] = useState(null); - const filterMenuButtonRef = useRef(null); - const [showFilterMenu, setShowFilterMenu] = useState(false); - const [filterMenuAnchorRect, setFilterMenuAnchorRect] = - useState(null); + const { + anchorRef: filterMenuButtonRef, + visible: showFilterMenu, + anchorRect: filterMenuAnchorRect, + close: closeFilterMenu, + toggle: toggleFilterMenu, + } = useAnchoredMenu(); const { data, isFetched } = useGoals(activeFilter); @@ -90,21 +96,13 @@ export function GoalsView({ return data.allGoals.filter((goal) => goal.status === activeFilter); }, [activeFilter, data]); - const handleFilterChange = useCallback((status: GoalStatus | null) => { - setActiveFilter(status); - setShowFilterMenu(false); - }, []); - - const handleToggleFilterMenu = useCallback(() => { - if (showFilterMenu) { - setShowFilterMenu(false); - return; - } - filterMenuButtonRef.current?.measureInWindow((x, y, width, height) => { - setFilterMenuAnchorRect({ x, y, width, height }); - setShowFilterMenu(true); - }); - }, [showFilterMenu]); + const handleFilterChange = useCallback( + (status: GoalStatus | null) => { + setActiveFilter(status); + closeFilterMenu(); + }, + [closeFilterMenu], + ); const listHeaderElement = ( <> @@ -114,9 +112,9 @@ export function GoalsView({ bottom={12} trailing={ - + - + } > @@ -167,7 +165,7 @@ export function GoalsView({ setShowFilterMenu(false)} + onClose={closeFilterMenu} width={200} estimatedHeight={200} > diff --git a/apps/mobile/components/habits/habit-row-trailing.tsx b/apps/mobile/components/habits/habit-row-trailing.tsx index f32b55661..19962fa85 100644 --- a/apps/mobile/components/habits/habit-row-trailing.tsx +++ b/apps/mobile/components/habits/habit-row-trailing.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { MoreVertical } from 'lucide-react-native' import type { NormalizedHabit } from '@orbit/shared/types/habit' import type { createTokensV2 } from '@/lib/theme' +import { MenuAnchorHost } from '@/components/ui/anchored-menu' import { ParentRing } from '@/components/ui/parent-ring' import type { StatusDotState } from '@/components/ui/status-dot' import { CheckCircle } from './habit-row-check-circle' @@ -110,7 +111,7 @@ export function HabitRowTrailing({ ) ) : null} {!isSelectMode && hasMenuActions ? ( - + - + ) : null} ) diff --git a/apps/mobile/components/habits/habit-row.tsx b/apps/mobile/components/habits/habit-row.tsx index 2ce3de8cd..90084dd2e 100644 --- a/apps/mobile/components/habits/habit-row.tsx +++ b/apps/mobile/components/habits/habit-row.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { Pressable, Text, @@ -19,8 +19,7 @@ import type { NormalizedHabit } from '@orbit/shared/types/habit' import { useTimeFormat } from '@/hooks/use-time-format' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' -import { AnchoredMenu } from '@/components/ui/anchored-menu' -import type { MenuAnchorRect } from '@/lib/anchored-menu' +import { AnchoredMenu, useAnchoredMenu } from '@/components/ui/anchored-menu' import { SelectCheck } from '@/components/ui/select-check' import type { StatusDotState } from '@/components/ui/status-dot' import { HabitRowContent, type HabitRowMetaPart } from './habit-row-content' @@ -136,11 +135,13 @@ export function HabitRow({ const emoji = habit.emoji - const menuButtonRef = useRef(null) - const [menuVisible, setMenuVisible] = useState(false) - const [menuAnchorRect, setMenuAnchorRect] = useState( - null, - ) + const { + anchorRef: menuButtonRef, + visible: menuVisible, + anchorRect: menuAnchorRect, + open: openAnchoredMenu, + close: closeAnchoredMenu, + } = useAnchoredMenu() const menuActivityAt = useRef(0) const hasMenuActions = @@ -156,16 +157,13 @@ export function HabitRow({ const openMenu = useCallback(() => { menuActivityAt.current = Date.now() - menuButtonRef.current?.measureInWindow((x, y, width, height) => { - setMenuAnchorRect({ x, y, width, height }) - setMenuVisible(true) - }) - }, []) + openAnchoredMenu() + }, [openAnchoredMenu]) const closeMenu = useCallback(() => { menuActivityAt.current = Date.now() - setMenuVisible(false) - }, []) + closeAnchoredMenu() + }, [closeAnchoredMenu]) const handlePress = () => { if (Date.now() - menuActivityAt.current < 500) return diff --git a/apps/mobile/components/today/today-habits-header.tsx b/apps/mobile/components/today/today-habits-header.tsx index 07ad7fb1c..5d3c97300 100644 --- a/apps/mobile/components/today/today-habits-header.tsx +++ b/apps/mobile/components/today/today-habits-header.tsx @@ -36,7 +36,7 @@ import { AppTextInput } from "@/components/ui/app-text-input"; import { ProgressBar } from "@/components/ui/progress-bar"; import { TagChip } from "@/components/ui/tag-chip"; import { SectionLabel } from "@/components/ui/section-label"; -import { AnchoredMenu } from "@/components/ui/anchored-menu"; +import { AnchoredMenu, MenuAnchorHost } from "@/components/ui/anchored-menu"; import { TodayAISummary } from "@/components/habits/today-ai-summary"; import { TodayDateNavigation } from "@/app/(tabs)/today-shell"; import type { MenuAnchorRect } from "@/lib/anchored-menu"; @@ -325,7 +325,7 @@ export function TodayHabitsHeader({ /> {currentActiveView !== "general" ? ( - + - + ) : null} - + - + ) : null} diff --git a/apps/mobile/components/ui/anchored-menu.tsx b/apps/mobile/components/ui/anchored-menu.tsx index f30b17265..4582fbf02 100644 --- a/apps/mobile/components/ui/anchored-menu.tsx +++ b/apps/mobile/components/ui/anchored-menu.tsx @@ -1,4 +1,12 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from 'react' import { Animated, Dimensions, @@ -11,6 +19,7 @@ import { } from 'react-native' import { getAnchoredMenuPosition, + getFallbackAnchorRect, type MenuAnchorRect, } from '@/lib/anchored-menu' import { createTokensV2, easings, radius, shadowsV2 } from '@/lib/theme' @@ -19,6 +28,76 @@ import { useAppTheme } from '@/lib/use-app-theme' type AppTokens = ReturnType +/** + * Single seam for an anchored (popover) menu: owns the trigger ref, open/close + * state and the measured anchor rect. `open`/`toggle` flip visibility + * synchronously and then refine the anchor position, so the menu never depends + * on a native measure callback firing (which silently no-ops on Android Fabric + * release builds). Pair with `MenuAnchorHost` on the trigger and `AnchoredMenu` + * for the panel. + */ +export interface AnchoredMenuController { + anchorRef: RefObject + visible: boolean + anchorRect: MenuAnchorRect | null + open: () => void + close: () => void + toggle: () => void +} + +export function useAnchoredMenu(): AnchoredMenuController { + const anchorRef = useRef(null) + const [visible, setVisible] = useState(false) + const [anchorRect, setAnchorRect] = useState(null) + + const measureAnchor = useCallback(() => { + anchorRef.current?.measureInWindow((x, y, width, height) => { + setAnchorRect({ x, y, width, height }) + }) + }, []) + + const open = useCallback(() => { + setVisible(true) + measureAnchor() + }, [measureAnchor]) + + const close = useCallback(() => { + setVisible(false) + }, []) + + const toggle = useCallback(() => { + if (visible) { + setVisible(false) + return + } + setVisible(true) + measureAnchor() + }, [measureAnchor, visible]) + + return { anchorRef, visible, anchorRect, open, close, toggle } +} + +interface MenuAnchorHostProps { + anchorRef: RefObject + children: ReactNode +} + +/** + * Host wrapper for a menu trigger. Renders a non-collapsible View so the anchor + * ref resolves to a real native view (a flattened view can make measurement + * no-op on Android), keeping the measure/open invariant in one place. + */ +export function MenuAnchorHost({ + anchorRef, + children, +}: Readonly) { + return ( + + {children} + + ) +} + interface AnchoredMenuProps { visible: boolean anchorRect: MenuAnchorRect | null @@ -93,11 +172,9 @@ export function AnchoredMenu({ }, [menuMotion.enterDuration, menuMotion.exitDuration, progress, visible]) const position = useMemo(() => { - if (!anchorRect) return null - const window = Dimensions.get('window') return getAnchoredMenuPosition({ - anchorRect, + anchorRect: anchorRect ?? getFallbackAnchorRect(window.width), viewportWidth: window.width, viewportHeight: window.height, menuWidth: width, @@ -105,7 +182,7 @@ export function AnchoredMenu({ }) }, [anchorRect, menuHeight, width]) - if (!shouldRender || !anchorRect || !position) { + if (!shouldRender) { return null } diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js index edbe7b7e8..7c166f2d2 100644 --- a/apps/mobile/eslint.config.js +++ b/apps/mobile/eslint.config.js @@ -3,6 +3,7 @@ const { defineConfig } = require("eslint/config") const expoConfig = require("eslint-config-expo/flat") const reactHooks = require("eslint-plugin-react-hooks") const noComments = require("../../eslint-rules/no-comments.cjs") +const noGorhomSheet = require("../../eslint-rules/no-gorhom-sheet.cjs") // https://github.com/expo/expo/issues/43758 — eslint-config-expo@55 bundles react-hooks v5 (no React-19/Compiler rules); strip its registration and re-register v7. const expoConfigArray = Array.isArray(expoConfig) ? expoConfig : [expoConfig] @@ -19,8 +20,12 @@ module.exports = defineConfig([ { files: ["**/*.{ts,tsx}"], ignores: ["**/*.d.ts"], - plugins: { local: { rules: { "no-comments": noComments } } }, - rules: { "local/no-comments": "error" }, + plugins: { + local: { + rules: { "no-comments": noComments, "no-gorhom-sheet": noGorhomSheet }, + }, + }, + rules: { "local/no-comments": "error", "local/no-gorhom-sheet": "error" }, }, { plugins: { "react-hooks": reactHooks }, diff --git a/apps/mobile/lib/anchored-menu.ts b/apps/mobile/lib/anchored-menu.ts index 36ceada94..b64a456b0 100644 --- a/apps/mobile/lib/anchored-menu.ts +++ b/apps/mobile/lib/anchored-menu.ts @@ -51,3 +51,22 @@ export function getAnchoredMenuPosition({ return { left, top, opensUp } } + +export const FALLBACK_ANCHOR_TOP_INSET = 56 + +/** + * Top-right fallback anchor used when a trigger's measured rect is unavailable — + * e.g. when Android Fabric's native `measureInWindow` silently no-ops — so an + * opened menu always paints near the top-right instead of rendering nothing. + */ +export function getFallbackAnchorRect( + viewportWidth: number, + margin = DEFAULT_ANCHORED_MENU_MARGIN, +): MenuAnchorRect { + return { + x: viewportWidth - margin, + y: FALLBACK_ANCHOR_TOP_INSET, + width: 0, + height: 0, + } +} diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts index 2ae6b46fe..caed92ec5 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -13,6 +13,7 @@ export const ArrowRight = createIcon('ArrowRight') export const BarChart3 = createIcon('BarChart3') export const Bell = createIcon('Bell') export const CalendarCheck = createIcon('CalendarCheck') +export const CalendarClock = createIcon('CalendarClock') export const Check = createIcon('Check') export const CheckCircle2 = createIcon('CheckCircle2') export const CalendarDays = createIcon('CalendarDays') @@ -37,6 +38,7 @@ export const MessageSquare = createIcon('MessageSquare') export const MinusCircle = createIcon('MinusCircle') export const MoreVertical = createIcon('MoreVertical') export const Palette = createIcon('Palette') +export const Pencil = createIcon('Pencil') export const PenSquare = createIcon('PenSquare') export const Plus = createIcon('Plus') export const PlusCircle = createIcon('PlusCircle') diff --git a/apps/mobile/test-mocks/react-native.ts b/apps/mobile/test-mocks/react-native.ts index 175fd0a9c..8d81f2959 100644 --- a/apps/mobile/test-mocks/react-native.ts +++ b/apps/mobile/test-mocks/react-native.ts @@ -5,6 +5,33 @@ type HostProps = Readonly<{ [key: string]: unknown }> +type MeasureInWindowCallback = ( + x: number, + y: number, + width: number, + height: number, +) => void +type MeasureInWindowImpl = (callback: MeasureInWindowCallback) => void + +const DEFAULT_MEASURE_IN_WINDOW: MeasureInWindowImpl = (callback) => + callback(0, 0, 32, 32) + +let measureInWindowImpl: MeasureInWindowImpl = DEFAULT_MEASURE_IN_WINDOW +let hostRefsNull = false + +export function __setMeasureInWindowImpl(impl: MeasureInWindowImpl) { + measureInWindowImpl = impl +} + +export function __setHostRefsNull(value: boolean) { + hostRefsNull = value +} + +export function __resetTestHostConfig() { + measureInWindowImpl = DEFAULT_MEASURE_IN_WINDOW + hostRefsNull = false +} + function createHostComponent(name: string) { const HostComponent = React.forwardRef(function HostComponent( { children, ...props }, @@ -12,14 +39,18 @@ function createHostComponent(name: string) { ) { const hostRef = { measure: (callback?: (...args: number[]) => void) => callback?.(0, 0, 32, 32, 0, 0), - measureInWindow: (callback?: (...args: number[]) => void) => callback?.(0, 0, 32, 32), + measureInWindow: (callback?: MeasureInWindowCallback) => { + if (callback) measureInWindowImpl(callback) + }, setNativeProps: () => {}, } - if (typeof ref === 'function') { - ref(hostRef) - } else if (ref && typeof ref === 'object') { - ;(ref as { current: unknown }).current = hostRef + if (!hostRefsNull) { + if (typeof ref === 'function') { + ref(hostRef) + } else if (ref && typeof ref === 'object') { + ;(ref as { current: unknown }).current = hostRef + } } return React.createElement(name, props, children as React.ReactNode) @@ -82,6 +113,12 @@ export const PanResponder = { export const Dimensions = { get: (_dimension: 'window' | 'screen') => ({ width: 412, height: 892 }), + addEventListener: ( + _event: string, + _listener: (...args: unknown[]) => void, + ) => ({ + remove: () => {}, + }), } export const Easing = { diff --git a/eslint-rules/no-gorhom-sheet.cjs b/eslint-rules/no-gorhom-sheet.cjs new file mode 100644 index 000000000..afb230508 --- /dev/null +++ b/eslint-rules/no-gorhom-sheet.cjs @@ -0,0 +1,93 @@ +/** + * Local ESLint rule: keep `@gorhom/bottom-sheet` out of apps/mobile and route + * every sheet through the shared wrapper. + * + * gorhom's `present()` + portal silently no-op on the New Architecture + * (Fabric/Bridgeless) in release builds, so the bug is invisible at runtime. + * This rule fails CI for the two reintroduction vectors: + * - importing `@gorhom/bottom-sheet` (anywhere), and + * - calling `.present()` / `.dismiss()` on a sheet ref (`someRef.current.present()`) + * outside the single sanctioned wrapper `components/bottom-sheet-modal.tsx`. + */ + +const GORHOM_MODULE = '@gorhom/bottom-sheet' +const SHEET_METHODS = new Set(['present', 'dismiss']) +const ALLOWED_SHEET_FILE = 'components/bottom-sheet-modal.tsx' + +function isCurrentMember(node) { + return ( + node && + node.type === 'MemberExpression' && + node.property && + node.property.type === 'Identifier' && + node.property.name === 'current' + ) +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Ban @gorhom/bottom-sheet and direct sheet-ref present()/dismiss() calls outside the shared bottom-sheet wrapper.', + }, + schema: [], + messages: { + noGorhomImport: + "Don't import '@gorhom/bottom-sheet'. Its present()/portal no-op on the New Architecture in release builds — use the shared BottomSheetModal (components/bottom-sheet-modal.tsx, backed by react-native-true-sheet).", + noSheetRefCall: + 'Route sheets through the shared BottomSheetModal wrapper. Calling {{method}}() on a sheet ref is only allowed in components/bottom-sheet-modal.tsx.', + }, + }, + create(context) { + const rawFilename = context.filename ?? context.getFilename() ?? '' + const filename = rawFilename.replace(/\\/g, '/') + const isWrapperFile = filename.endsWith(ALLOWED_SHEET_FILE) + + function reportGorhomSource(node) { + if (node && node.value === GORHOM_MODULE) { + context.report({ node, messageId: 'noGorhomImport' }) + } + } + + return { + ImportDeclaration(node) { + reportGorhomSource(node.source) + }, + ImportExpression(node) { + reportGorhomSource(node.source) + }, + CallExpression(node) { + const callee = node.callee + + if (callee.type !== 'MemberExpression') { + if ( + callee.type === 'Identifier' && + callee.name === 'require' && + node.arguments.length === 1 && + node.arguments[0].type === 'Literal' + ) { + reportGorhomSource(node.arguments[0]) + } + return + } + + if (isWrapperFile) return + + const property = callee.property + if ( + property && + property.type === 'Identifier' && + SHEET_METHODS.has(property.name) && + isCurrentMember(callee.object) + ) { + context.report({ + node, + messageId: 'noSheetRefCall', + data: { method: property.name }, + }) + } + }, + } + }, +}