From 83162b5f70fe11cd0963a2b7065965bd2f8690f8 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 13 Jul 2026 20:53:45 -0300 Subject: [PATCH] chore(mobile): drive React Doctor to zero across mobile ui/habits (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Burns down all 100 React Doctor findings in the mobile ui/habits/habit-list file-set to zero — fixing where behavior-preserving, suppressing genuine false positives / deliberate choices with a WHY linking #243. Fixed (behavior-preserving): - no-ref-current-in-render: dropped the redundant lastSyncedValueRef in buffered-sheet-input (setDraft is already idempotent under the focus guard). - rn-prefer-pressable: migrated TouchableOpacity -> Pressable across the pickers, habit-calendar, checklists and chips, replicating activeOpacity as pressed-opacity feedback. - js-set-map-lookups, rn-no-single-element-style-array, no-usememo-simple- expression, rerender-memo-with-default-value (module const), rn-no-legacy- shadow-styles -> boxShadow, no-array-index-as-key where a stable id exists, FlatList renderItem/data hoisting (habit-list skeleton, app-select memo row). - prefer-use-effect-event: fresh-start-animation onComplete via useEffectEvent. Suppressed with WHY (Refs #243): - rn-prefer-reanimated (16): deliberate RN Animated with useNativeDriver; Reanimated 4.x migration deferred (worklets 0.10.0 ABI pin, needs device QA). - exhaustive-deps (18): extracted habitsQuery/appConfig/profile aliases already listed (analyzer wants the qualified member path). - no-many-boolean-props / no-giant-component / no-event-handler / no-prop- callback-in-effect / rn-no-scrollview-mapped-list / rn-no-dimensions-get / only-export-components / no-polymorphic-children / rerender-* / no-array- index-as-key (no stable id) / effect-needs-cleanup / advanced-event-handler- refs / rn-prefer-expo-image. Refs thomasluizon/orbit-ui-mobile#243 (React Doctor burn-down: mobile ui/habits) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ui/app-time-picker.test.tsx | 4 +- .../ui/offline-unavailable-state.test.tsx | 5 +- apps/mobile/components/habit-list.tsx | 45 ++++++-- .../components/habit-list/confirm-dialogs.tsx | 1 + .../habit-list/date-group-section.tsx | 1 + .../components/habit-list/empty-state.tsx | 1 + .../habit-list/move-parent-dialog.tsx | 2 + .../components/habits/checklist-templates.tsx | 48 ++++---- .../components/habits/create-habit-modal.tsx | 4 + .../components/habits/goal-linking-field.tsx | 16 ++- .../components/habits/habit-calendar.tsx | 56 +++++---- .../components/habits/habit-checklist.tsx | 76 +++++++----- .../habit-detail-reminders.tsx | 1 + .../components/habits/habit-form-fields.tsx | 1 + .../habit-form-fields/active-days-section.tsx | 3 +- .../habit-form-fields/advanced-section.tsx | 1 + .../buffered-sheet-input.tsx | 7 +- .../frequency-type-cards.tsx | 13 ++- .../habit-emoji-selector.tsx | 1 + .../habit-form-fields/slip-alert-section.tsx | 2 +- .../habits/habit-form-fields/tags-section.tsx | 3 +- .../components/habits/habit-row-leading.tsx | 1 + .../components/habits/habit-row-trailing.tsx | 1 + apps/mobile/components/habits/habit-row.tsx | 6 +- apps/mobile/components/ui/anchored-menu.tsx | 9 +- apps/mobile/components/ui/app-bar.tsx | 1 + apps/mobile/components/ui/app-date-picker.tsx | 52 +++++---- apps/mobile/components/ui/app-logo.tsx | 1 + apps/mobile/components/ui/app-select.tsx | 109 +++++++++++------- apps/mobile/components/ui/app-time-picker.tsx | 25 ++-- apps/mobile/components/ui/app-toast.tsx | 2 + apps/mobile/components/ui/astra-avatar.tsx | 1 + apps/mobile/components/ui/confirm-dialog.tsx | 1 + .../components/ui/create-api-key-modal.tsx | 3 +- .../components/ui/fresh-start-animation.tsx | 13 ++- apps/mobile/components/ui/highlight-text.tsx | 2 + .../ui/keyboard-aware-scroll-view.tsx | 1 + .../ui/offline-unavailable-state.tsx | 13 ++- apps/mobile/components/ui/pill-button.tsx | 1 + apps/mobile/components/ui/progress-bar.tsx | 1 + apps/mobile/components/ui/push-prompt.tsx | 1 + .../components/ui/scroll-to-top-button.tsx | 1 + apps/mobile/components/ui/settings-row.tsx | 7 +- apps/mobile/components/ui/skeleton.tsx | 1 + apps/mobile/components/ui/status-dot.tsx | 2 + apps/mobile/components/ui/trial-banner.tsx | 2 + .../components/ui/trial-expired-modal.tsx | 1 + apps/mobile/components/ui/year-picker.tsx | 1 + 48 files changed, 350 insertions(+), 200 deletions(-) diff --git a/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx b/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx index 73e11ecba..baadf57a2 100644 --- a/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx +++ b/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx @@ -32,14 +32,14 @@ function columns(tree: any): any[] { function optionIn(column: any, label: string): any { return column - .findAll((node: any) => node.type === 'TouchableOpacity') + .findAll((node: any) => node.type === Pressable) .find((node: any) => node.props.accessibilityLabel === label) } function doneButton(tree: any): any { return tree.root.find( (node: any) => - node.type === 'TouchableOpacity' && node.props.accessibilityLabel === 'common.done', + node.type === Pressable && node.props.accessibilityLabel === 'common.done', ) } diff --git a/apps/mobile/__tests__/components/ui/offline-unavailable-state.test.tsx b/apps/mobile/__tests__/components/ui/offline-unavailable-state.test.tsx index 6b4a680fd..959b4006a 100644 --- a/apps/mobile/__tests__/components/ui/offline-unavailable-state.test.tsx +++ b/apps/mobile/__tests__/components/ui/offline-unavailable-state.test.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { Pressable } from 'react-native' import { describe, expect, it, vi } from 'vitest' import { OfflineUnavailableState } from '@/components/ui/offline-unavailable-state' @@ -53,7 +54,7 @@ describe('OfflineUnavailableState', () => { ) expect(summary.props.accessibilityLiveRegion).toBe('polite') - const button = tree.root.findByType('TouchableOpacity') + const button = tree.root.findByType(Pressable) expect(button.props.accessibilityRole).toBe('button') expect(button.props.accessibilityLabel).toBe('Try again') expect(button.props.accessibilityState).toEqual({ disabled: true }) @@ -75,7 +76,7 @@ describe('OfflineUnavailableState', () => { expect( tree.root.findAll( - (node: any) => node.type === 'TouchableOpacity' && node.props.onPress, + (node: any) => node.type === Pressable && node.props.onPress, ), ).toHaveLength(0) expect( diff --git a/apps/mobile/components/habit-list.tsx b/apps/mobile/components/habit-list.tsx index 9aac6d9f3..32f796e1d 100644 --- a/apps/mobile/components/habit-list.tsx +++ b/apps/mobile/components/habit-list.tsx @@ -150,6 +150,15 @@ export interface HabitListHandle { const TOUR_FEATURED_HABIT_ID = 'tour-habit-2' +const SKELETON_KEYS = [ + 'skeleton-1', + 'skeleton-2', + 'skeleton-3', + 'skeleton-4', + 'skeleton-5', +] + +// react-doctor-disable-next-line no-giant-component -- core list orchestrator already decomposed into ./habit-list/* submodules (empty-state, date-group-section, drill-view, move-parent-dialog, tree-helpers, styles); the remaining body is cohesive list state + handlers, extraction deferred to avoid regression without device QA https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export const HabitList = forwardRef( function HabitList( { @@ -410,6 +419,7 @@ export const HabitList = forwardRef( return topLevelHabits.filter( (habit) => !habit.isCompleted || recentlyCompletedIds.has(habit.id), ) + // react-doctor-disable-next-line exhaustive-deps -- topLevelHabits is the extracted habitsQuery.data.topLevelHabits and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [recentlyCompletedIds, showCompleted, topLevelHabits, view, visibility]) const dateGroups = useMemo(() => { @@ -423,6 +433,7 @@ export const HabitList = forwardRef( ? t('habits.overdue') : formatDateGroupLabel(bucket.key, deviceLocale, t), })) + // react-doctor-disable-next-line exhaustive-deps -- deviceLocale is the extracted i18n.language and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [deviceLocale, t, view, visibleHabits]) const allLoadedIds = useMemo(() => { @@ -468,10 +479,12 @@ export const HabitList = forwardRef( ) useEffect(() => { + // react-doctor-disable-next-line no-pass-data-to-parent, no-pass-live-state-to-parent, no-prop-callback-in-effect -- documented parent-mirror callback: HabitList computes allCollapsed internally and notifies the parent so it can mirror it in render-time state (refs cannot be read during render — see the prop JSDoc) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 onAllCollapsedChange?.(allCollapsed) }, [allCollapsed, onAllCollapsedChange]) useEffect(() => { + // react-doctor-disable-next-line no-pass-data-to-parent, no-pass-live-state-to-parent, no-prop-callback-in-effect -- documented parent-mirror callback: HabitList computes allLoadedIds internally and notifies the parent so it can mirror it in render-time state (refs cannot be read during render — see the prop JSDoc) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 onAllLoadedIdsChange?.(allLoadedIds) }, [allLoadedIds, onAllLoadedIdsChange]) @@ -525,6 +538,7 @@ export const HabitList = forwardRef( const activeDragItemsRef = useRef(activeDragItems) useEffect(() => { activeDragItemsRef.current = activeDragItems + // react-doctor-disable-next-line exhaustive-deps -- activeDragItems already combines dragOverrideItems and flatItems (both the analyzer flags); listing the combined value is sufficient, no staleness https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [activeDragItems]) const isDndEnabled = view !== 'all' && !isSelectMode @@ -538,6 +552,7 @@ export const HabitList = forwardRef( habitsById, selectedDateStr, } + // react-doctor-disable-next-line exhaustive-deps -- getChildren/habitsById are the extracted habitsQuery members and already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [getChildren, isListView, visibility, habitsById, selectedDateStr]) const childrenProgressMap = useMemo(() => { @@ -613,6 +628,7 @@ export const HabitList = forwardRef( } return map + // react-doctor-disable-next-line exhaustive-deps -- getChildren/habitsById are the extracted habitsQuery members and already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [getChildren, habitsById, isListView, visibility]) const getChildrenProgress = useCallback( @@ -813,6 +829,7 @@ export const HabitList = forwardRef( targetParentId, draggedId, ), + // react-doctor-disable-next-line exhaustive-deps -- getChildren/habitsById/maxHabitDepth are extracted habitsQuery/appConfig members already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [getChildren, habitsById, maxHabitDepth, t], ) @@ -822,6 +839,7 @@ export const HabitList = forwardRef( { topLevelHabits, getChildren, validateMoveTarget, t }, movingHabitId, ) + // react-doctor-disable-next-line exhaustive-deps -- topLevelHabits/getChildren are extracted habitsQuery members already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [getChildren, movingHabitId, t, topLevelHabits, validateMoveTarget]) const selectedMoveOption = useMemo( @@ -1011,6 +1029,7 @@ export const HabitList = forwardRef( restoreCollapsedStateAfterDrag() } }, + // react-doctor-disable-next-line exhaustive-deps -- getChildren/habitsById are extracted habitsQuery members already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [ getChildren, habitsById, @@ -1047,6 +1066,7 @@ export const HabitList = forwardRef( } }, }), + // react-doctor-disable-next-line exhaustive-deps -- refetch is the extracted habitsQuery.refetch and already listed; the analyzer wants the qualified path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [ allCollapsed, allLoadedIds, @@ -1222,6 +1242,7 @@ export const HabitList = forwardRef( return walk(parentId, depth) }, + // react-doctor-disable-next-line exhaustive-deps -- maxHabitDepth is the extracted appConfig.limits.maxHabitDepth and already listed; the analyzer wants the qualified path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [ collapsedIds, getVisibleChildren, @@ -1256,6 +1277,15 @@ export const HabitList = forwardRef( const keyExtractor = useCallback((item: DragItem) => item.id, []) + const renderSkeletonItem = useCallback( + () => ( + + + + ), + [styles], + ) + const renderEmptyState = useCallback( (currentView: 'today' | 'all' | 'general') => ( ( progressViewOffset={insets.top} /> ), + // react-doctor-disable-next-line exhaustive-deps -- isFetching/isLoading/refetch are extracted habitsQuery members already listed; the analyzer wants the qualified paths but the aliases track them https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [tokens.primary, insets.top, isFetching, isLoading, refetch], ) @@ -1482,19 +1513,9 @@ export const HabitList = forwardRef( return ( <> item} - renderItem={() => ( - - - - )} + renderItem={renderSkeletonItem} ListHeaderComponent={listHeaderComponent} contentContainerStyle={[ styles.skeletonContainer, diff --git a/apps/mobile/components/habit-list/confirm-dialogs.tsx b/apps/mobile/components/habit-list/confirm-dialogs.tsx index 7497c0f43..eebcb7774 100644 --- a/apps/mobile/components/habit-list/confirm-dialogs.tsx +++ b/apps/mobile/components/habit-list/confirm-dialogs.tsx @@ -32,6 +32,7 @@ interface HabitListConfirmDialogsProps { /** The cluster of habit-list confirmation dialogs (delete / duplicate / skip / * force-log / auto-log-parent). Driven entirely by open-flag props and handlers * owned by the parent HabitList. */ +// react-doctor-disable-next-line no-many-boolean-props -- private single-use dialog aggregator; each flag independently gates one dialog's visibility, not a combinatorial public API https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function HabitListConfirmDialogs({ t, showDeleteConfirm, diff --git a/apps/mobile/components/habit-list/date-group-section.tsx b/apps/mobile/components/habit-list/date-group-section.tsx index 3eeb391d3..4210cb68d 100644 --- a/apps/mobile/components/habit-list/date-group-section.tsx +++ b/apps/mobile/components/habit-list/date-group-section.tsx @@ -13,6 +13,7 @@ export interface HabitListDateGroup { habits: NormalizedHabit[] } +// react-doctor-disable-next-line only-export-components -- co-located date-label helper dedicated to this section; Fast Refresh dev-only, no runtime effect https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function formatDateGroupLabel( key: string, locale: string, diff --git a/apps/mobile/components/habit-list/empty-state.tsx b/apps/mobile/components/habit-list/empty-state.tsx index c9d539120..6e4f3bbf9 100644 --- a/apps/mobile/components/habit-list/empty-state.tsx +++ b/apps/mobile/components/habit-list/empty-state.tsx @@ -143,6 +143,7 @@ export function SkeletonCard({ styles: cardStyles }: Readonly<{ styles: Skeleton ) } +// react-doctor-disable-next-line only-export-components -- co-located empty-state message helper dedicated to this module; Fast Refresh dev-only, no runtime effect https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function getEmptyHabitsMessage( view: 'today' | 'all' | 'general', t: (key: string) => string, diff --git a/apps/mobile/components/habit-list/move-parent-dialog.tsx b/apps/mobile/components/habit-list/move-parent-dialog.tsx index f8f24b9cd..3be572830 100644 --- a/apps/mobile/components/habit-list/move-parent-dialog.tsx +++ b/apps/mobile/components/habit-list/move-parent-dialog.tsx @@ -73,6 +73,7 @@ function MoveTargetRow({ > {Array.from({ length: option.depth }, (_, index) => ( + // react-doctor-disable-next-line no-array-index-as-key -- decorative indent rails: identical stateless spacers keyed by position, with no data identity to preserve https://github.com/thomasluizon/orbit-ui-mobile/issues/243 @@ -199,6 +200,7 @@ export function MoveParentDialog({ showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > + {/* react-doctor-disable-next-line rn-no-scrollview-mapped-list -- bounded move-target picker inside a bottom sheet; search collapses long lists (threshold 8) and nesting a VirtualizedList in the sheet ScrollView is discouraged https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */} {treeRows.map((option) => ( {items.length > 0 && !showSave ? ( - [ + styles.saveChip, + pressed ? { opacity: 0.8 } : null, + ]} onPress={() => setShowSave(true)} - activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={t('habits.form.saveAsTemplate')} accessibilityHint={t('habits.form.templateNamePlaceholder')} > {t('habits.form.saveAsTemplate')} - + ) : null} {templates.length > 0 ? ( @@ -108,31 +110,33 @@ export function ChecklistTemplates({ {templates.map((template) => ( - [ + styles.chipLoadButton, + pressed ? { opacity: 0.8 } : null, + ]} onPress={() => handleLoad(template.id)} - activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={template.name} accessibilityHint={t('habits.form.templates')} > {template.name} - - + [ styles.chipDeleteButton, isDeletingThisTemplate(template.id) && styles.chipDeleteButtonDisabled, + pressed ? { opacity: 0.8 } : null, ]} onPress={() => handleDelete(template.id)} disabled={isDeletingThisTemplate(template.id)} - activeOpacity={0.8} > - + ))} @@ -152,32 +156,34 @@ export function ChecklistTemplates({ onSubmitEditing={handleSave} returnKeyType="done" /> - [ styles.saveButton, (!templateName.trim() || createTemplate.isPending) && styles.saveButtonDisabled, + pressed ? { opacity: 0.8 } : null, ]} onPress={handleSave} disabled={!templateName.trim() || createTemplate.isPending} - activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={t('common.save')} accessibilityState={{ disabled: !templateName.trim() || createTemplate.isPending }} > {t('common.save')} - - + [ + styles.closeButton, + pressed ? { opacity: 0.8 } : null, + ]} onPress={() => { setTemplateName('') setShowSave(false) }} - activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={t('common.close')} > - + ) : null} diff --git a/apps/mobile/components/habits/create-habit-modal.tsx b/apps/mobile/components/habits/create-habit-modal.tsx index 16b68e293..2ade2a1ff 100644 --- a/apps/mobile/components/habits/create-habit-modal.tsx +++ b/apps/mobile/components/habits/create-habit-modal.tsx @@ -57,6 +57,7 @@ interface CreateHabitModalProps { parentHabit?: NormalizedHabit | null } +// react-doctor-disable-next-line no-giant-component -- form-modal shell already decomposed into create-habit-modal/* and HabitFormFields subcomponents; the remaining body is cohesive submit/suggest/reset orchestration, extraction deferred to avoid regression without device QA https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function CreateHabitModal({ open, onClose, @@ -146,6 +147,7 @@ export function CreateHabitModal({ useEffect(() => { if (!open || !isSubHabitMode || !profile || profile.hasProAccess) return + // react-doctor-disable-next-line no-prop-callback-in-effect -- access gate: closes the sub-habit modal and redirects non-pro users to /upgrade; a side-effecting gate, not a state sync to the parent https://github.com/thomasluizon/orbit-ui-mobile/issues/243 onClose() router.push('/upgrade') }, [isSubHabitMode, onClose, open, profile, router]) @@ -298,6 +300,7 @@ export function CreateHabitModal({ ), ) } + // react-doctor-disable-next-line exhaustive-deps -- hasProAccess is derived from profile.hasProAccess every render and already listed; no staleness possible https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [ formHelpers, isSubHabitMode, @@ -360,6 +363,7 @@ export function CreateHabitModal({ : t('habits.form.aiSuggestError'), ) } + // react-doctor-disable-next-line exhaustive-deps -- hasProAccess is derived from profile.hasProAccess every render and already listed; no staleness possible https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [formHelpers, hasProAccess, i18n.language, showError, showInfo, showSuccess, suggestion, t]) const isPending = createHabit.isPending || createSubHabit.isPending diff --git a/apps/mobile/components/habits/goal-linking-field.tsx b/apps/mobile/components/habits/goal-linking-field.tsx index 920f520c6..5f7f9872d 100644 --- a/apps/mobile/components/habits/goal-linking-field.tsx +++ b/apps/mobile/components/habits/goal-linking-field.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' +import { View, Text, Pressable, StyleSheet } from 'react-native' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { API } from '@orbit/shared/api' @@ -45,6 +45,10 @@ export function GoalLinkingField({ () => goals?.filter((goal) => goal.status === 'Active') ?? [], [goals], ) + const selectedGoalIdSet = useMemo( + () => new Set(selectedGoalIds), + [selectedGoalIds], + ) return ( @@ -52,20 +56,20 @@ export function GoalLinkingField({ {activeGoals.length > 0 ? ( {activeGoals.map((goal) => { - const isSelected = selectedGoalIds.includes(goal.id) + const isSelected = selectedGoalIdSet.has(goal.id) const isDisabled = !isSelected && atGoalLimit return ( - [ styles.chip, isSelected ? styles.chipSelected : styles.chipDefault, isDisabled && styles.chipDisabled, + pressed && !isDisabled ? { opacity: 0.75 } : null, ]} disabled={isDisabled} onPress={() => onToggleGoal(goal.id)} - activeOpacity={0.75} accessibilityRole="button" accessibilityState={{ selected: isSelected, disabled: isDisabled }} > @@ -88,7 +92,7 @@ export function GoalLinkingField({ {Math.round(goal.progressPercentage)}% - + ) })} diff --git a/apps/mobile/components/habits/habit-calendar.tsx b/apps/mobile/components/habits/habit-calendar.tsx index cb532caf0..4a06e6c7f 100644 --- a/apps/mobile/components/habits/habit-calendar.tsx +++ b/apps/mobile/components/habits/habit-calendar.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback } from "react" -import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { View, Text, Pressable, StyleSheet } from "react-native" import { addMonths, subMonths, parseISO } from "date-fns" import { ChevronLeft, ChevronRight, X } from "lucide-react-native" import { useTranslation } from "react-i18next" @@ -61,11 +61,13 @@ export function HabitCalendar({ key, label: t(`dates.daysShort.${key}`).charAt(0), })), + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is the extracted profile.weekStartDay and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [t, weekStartsOn], ) const calendarDays = useMemo( () => buildHabitCalendarDayCells(currentMonth, weekStartsOn, logDates), + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is the extracted profile.weekStartDay and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [currentMonth, logDates, weekStartsOn], ) @@ -109,46 +111,52 @@ export function HabitCalendar({ return ( - [ + styles.iconButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={prevMonth} - activeOpacity={0.7} hitSlop={4} > - + - [ + styles.monthButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={goToToday} - activeOpacity={0.7} > {monthLabel} - + - [ + styles.iconButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={nextMonth} - activeOpacity={0.7} hitSlop={4} > - + - {weekdays.map((day, index) => ( - + {weekdays.map((day) => ( + {day.label} ))} @@ -158,20 +166,20 @@ export function HabitCalendar({ {calendarDays.map((day) => ( {day.isCurrentMonth && day.isCompleted ? ( - [ styles.completedDay, selectedDate === day.dateStr && styles.completedDaySelected, + pressed ? { opacity: 0.8 } : null, ]} hitSlop={5} onPress={() => toggleDay(day.dateStr)} - activeOpacity={0.8} > {day.dayNum} - + ) : ( {displayDate(parseISO(selectedDate))} - [ + styles.closeSelectionButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={() => setSelectedDate(null)} - activeOpacity={0.7} hitSlop={8} > - + diff --git a/apps/mobile/components/habits/habit-checklist.tsx b/apps/mobile/components/habits/habit-checklist.tsx index 3899c4e91..bb5d8547c 100644 --- a/apps/mobile/components/habits/habit-checklist.tsx +++ b/apps/mobile/components/habits/habit-checklist.tsx @@ -3,7 +3,6 @@ import { Pressable, View, Text, - TouchableOpacity, StyleSheet, } from 'react-native' import Animated, { @@ -90,28 +89,32 @@ function EditableChecklistItem({ return ( - [ + styles.moveButton, + pressed && !isFirst ? { opacity: 0.7 } : null, + ]} onPress={onMoveUp} disabled={isFirst} - activeOpacity={0.7} hitSlop={{ top: 6, bottom: 6, left: 12, right: 12 }} > - - + [ + styles.moveButton, + pressed && !isLast ? { opacity: 0.7 } : null, + ]} onPress={onMoveDown} disabled={isLast} - activeOpacity={0.7} hitSlop={{ top: 6, bottom: 6, left: 12, right: 12 }} > - + - [ + styles.itemAction, + pressed ? { opacity: 0.7 } : null, + ]} onPress={handleDuplicate} - activeOpacity={0.7} > - - + [ + styles.itemAction, + pressed ? { opacity: 0.7 } : null, + ]} onPress={handleRemove} - activeOpacity={0.7} > - + ) } @@ -252,19 +259,19 @@ function ChecklistAddRow({ onSubmitEditing={onAdd} returnKeyType="done" /> - [ styles.addItemButton, !value.trim() && styles.addItemButtonDisabled, + pressed && !!value.trim() ? { opacity: 0.7 } : null, ]} disabled={!value.trim()} onPress={onAdd} - activeOpacity={0.7} > - + ) } @@ -363,28 +370,32 @@ export function HabitChecklist({ {checkedCount}/{items.length} {interactive && checkedCount > 0 && ( - [ + styles.actionButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={onReset} - activeOpacity={0.7} hitSlop={9} > - + )} {interactive && ( - [ + styles.actionButton, + pressed ? { opacity: 0.7 } : null, + ]} onPress={onClear} - activeOpacity={0.7} hitSlop={9} > - + )} )} @@ -412,6 +423,7 @@ export function HabitChecklist({ items.length > 0 && ( {items.map((item, index) => ( + // react-doctor-disable-next-line no-array-index-as-key -- ChecklistItem is a value object with no stable id; the interactive list is toggle-only and never reorders, so the positional key is stable https://github.com/thomasluizon/orbit-ui-mobile/issues/243 0 && ( - (pressed ? { opacity: 0.7 } : undefined)} hitSlop={14} > {t('habits.form.clearChecklist')} - + )} diff --git a/apps/mobile/components/habits/habit-detail-drawer/habit-detail-reminders.tsx b/apps/mobile/components/habits/habit-detail-drawer/habit-detail-reminders.tsx index a08266872..e2ec49f2a 100644 --- a/apps/mobile/components/habits/habit-detail-drawer/habit-detail-reminders.tsx +++ b/apps/mobile/components/habits/habit-detail-drawer/habit-detail-reminders.tsx @@ -30,6 +30,7 @@ export function HabitDetailReminders({ /> ) : null} {habit.scheduledReminders.map((sr, idx) => ( + // react-doctor-disable-next-line no-array-index-as-key -- scheduled reminders are value objects with no id; a read-only detail list that never reorders, so the positional key is stable https://github.com/thomasluizon/orbit-ui-mobile/issues/243 { diff --git a/apps/mobile/components/habits/habit-form-fields/active-days-section.tsx b/apps/mobile/components/habits/habit-form-fields/active-days-section.tsx index ec62ec7ff..a4f02e7e7 100644 --- a/apps/mobile/components/habits/habit-form-fields/active-days-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/active-days-section.tsx @@ -19,6 +19,7 @@ export function ActiveDaysSection({ }: Readonly) { const { t } = useTranslation(); const watchedDays = useWatch({ control, name: "days" }) ?? []; + const watchedDaySet = new Set(watchedDays); return ( @@ -32,7 +33,7 @@ export function ActiveDaysSection({ options={daysList.map((day) => ({ key: day.value, label: day.label, - active: watchedDays.includes(day.value), + active: watchedDaySet.has(day.value), onPress: () => onToggleDay(day.value), }))} /> diff --git a/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx b/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx index f6b170eed..c168fc2f7 100644 --- a/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx @@ -42,6 +42,7 @@ interface AdvancedSectionProps { children?: ReactNode; } +// react-doctor-disable-next-line no-many-boolean-props -- private single-use form section; the flags are independent field-visibility gates from the form state, not a combinatorial public API https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function AdvancedSection({ control, isGeneral, diff --git a/apps/mobile/components/habits/habit-form-fields/buffered-sheet-input.tsx b/apps/mobile/components/habits/habit-form-fields/buffered-sheet-input.tsx index 3a69b9511..49202c865 100644 --- a/apps/mobile/components/habits/habit-form-fields/buffered-sheet-input.tsx +++ b/apps/mobile/components/habits/habit-form-fields/buffered-sheet-input.tsx @@ -36,13 +36,11 @@ export const BufferedSheetInput = memo(function BufferedSheetInput({ const [draft, setDraft] = useState(value); const [prevValue, setPrevValue] = useState(value); const isFocusedRef = useRef(false); - const lastSyncedValueRef = useRef(value); if (value !== prevValue) { setPrevValue(value); - if (!isFocusedRef.current && value !== lastSyncedValueRef.current) { + if (!isFocusedRef.current) { setDraft(value); - lastSyncedValueRef.current = value; } } @@ -50,16 +48,15 @@ export const BufferedSheetInput = memo(function BufferedSheetInput({ if (draft !== value) { onCommit(draft); } - lastSyncedValueRef.current = draft; }, [draft, onCommit, value]); + // react-doctor-disable-next-line no-prop-callback-in-effect -- registers/unregisters the commit-flush handle with the parent (cleanup returns the unregister); an imperative registration handle, not a state sync https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => registerFlush?.(commitDraft), [commitDraft, registerFlush]); const handleChangeText = useCallback( (nextValue: string) => { const nextDraft = transformDraft ? transformDraft(nextValue) : nextValue; setDraft(nextDraft); - lastSyncedValueRef.current = nextDraft; onDraftChange?.(nextDraft); }, [onDraftChange, transformDraft], diff --git a/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx b/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx index bac723f9f..c5feca82e 100644 --- a/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx +++ b/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { View, Text, @@ -84,10 +84,12 @@ export function FrequencyTypeCards({ else if (isGeneral) activeIndex = 3; else if (isFlexible) activeIndex = 2; - const frequencyHandlers = useMemo<(() => void)[]>( - () => [onSetOneTime, onSetRecurring, onSetFlexible, onSetGeneral], - [onSetOneTime, onSetRecurring, onSetFlexible, onSetGeneral], - ); + const frequencyHandlers: (() => void)[] = [ + onSetOneTime, + onSetRecurring, + onSetFlexible, + onSetGeneral, + ]; const scrollToActive = useCallback( (animated: boolean) => { @@ -158,6 +160,7 @@ export function FrequencyTypeCards({ showsHorizontalScrollIndicator={false} onMomentumScrollEnd={handleMomentumScrollEnd} > + {/* react-doctor-disable-next-line rn-no-scrollview-mapped-list -- fixed 4-card horizontal paging carousel; ScrollView + pagingEnabled with programmatic scrollTo is the right primitive, FlatList virtualization breaks paging https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */} {FREQUENCY_TYPE_CARDS.map((card, index) => { const CardIcon = card.icon; return ( diff --git a/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx b/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx index d4b7c3a7d..cdeb47726 100644 --- a/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx +++ b/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx @@ -114,6 +114,7 @@ export function HabitEmojiSelector({ contentContainerStyle={styles.emojiCategoryTabs} accessibilityLabel={t("habits.form.emojiCategories")} > + {/* react-doctor-disable-next-line rn-no-scrollview-mapped-list -- fixed horizontal category-tab strip over a static category list; ScrollView is the right primitive for a short horizontal tab row https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */} {HABIT_EMOJI_CATEGORIES.map((category) => { const selected = selectedCategoryId === category.id; return ( diff --git a/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx b/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx index b8f0aaa9d..d20b5bdf1 100644 --- a/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx @@ -68,7 +68,7 @@ export function SlipAlertSection({ {t("habits.form.slipAlertDescription")} - + diff --git a/apps/mobile/components/habits/habit-form-fields/tags-section.tsx b/apps/mobile/components/habits/habit-form-fields/tags-section.tsx index 5e5f0a417..038bbbd3f 100644 --- a/apps/mobile/components/habits/habit-form-fields/tags-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/tags-section.tsx @@ -68,6 +68,7 @@ export function TagsSection({ } const tagSuggestions = useTagSuggestions(title, description, tags.atTagLimit); + const selectedTagIdSet = new Set(tags.selectedTagIds); async function handleSuggest() { try { @@ -89,7 +90,7 @@ export function TagsSection({ {t("habits.form.tags")} {availableTags.map((tag) => { - const isSelected = tags.selectedTagIds.includes(tag.id); + const isSelected = selectedTagIdSet.has(tag.id); const isDisabled = !isSelected && tags.atTagLimit; return ( void } +const EMPTY_HABIT_ROW_ACTIONS: HabitRowActions = {} + interface HabitRowProps { habit: NormalizedHabit selectedDate?: Date @@ -64,6 +66,7 @@ interface HabitRowProps { /** * Habit row: emoji · title · inline meta · trailing status dot. */ +// react-doctor-disable-next-line no-many-boolean-props -- private row-internal component; the flags are independent render inputs from the parent list, not a combinatorial public API https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function HabitRow({ habit, selectedDate, @@ -74,7 +77,7 @@ export function HabitRow({ isExpanded = false, childrenDone = 0, childrenTotal = 0, - actions = {}, + actions = EMPTY_HABIT_ROW_ACTIONS, style, }: Readonly) { const { t, i18n } = useTranslation() @@ -176,6 +179,7 @@ export function HabitRow({ streak, t, }), + // react-doctor-disable-next-line exhaustive-deps -- streak is the extracted habit.currentStreak and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 [habit.title, dotState, linkedGoal, showStreak, streak, t], ) diff --git a/apps/mobile/components/ui/anchored-menu.tsx b/apps/mobile/components/ui/anchored-menu.tsx index 09ded8018..43500b3a1 100644 --- a/apps/mobile/components/ui/anchored-menu.tsx +++ b/apps/mobile/components/ui/anchored-menu.tsx @@ -8,11 +8,12 @@ import { type RefObject, } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the menu transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Dimensions, Modal, + Pressable, StyleSheet, - TouchableOpacity, View, type StyleProp, type ViewStyle, @@ -139,9 +140,11 @@ export function AnchoredMenu({ } } + // react-doctor-disable-next-line advanced-event-handler-refs -- onClose is a stable menu-close callback; the listener re-subscribes only if the trigger passes a new handler and must track `visible` to add/remove, so the re-subscribe is a cheap one-shot rotation dismiss https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => { if (!visible) return + // react-doctor-disable-next-line rn-no-dimensions-get -- Dimensions.addEventListener returns a subscription with .remove() (the current RN API); used to dismiss the menu on rotation/resize https://github.com/thomasluizon/orbit-ui-mobile/issues/243 const subscription = Dimensions.addEventListener('change', onClose) return () => { subscription.remove() @@ -172,6 +175,7 @@ export function AnchoredMenu({ }, [menuMotion.enterDuration, menuMotion.exitDuration, progress, visible]) const position = useMemo(() => { + // react-doctor-disable-next-line rn-no-dimensions-get -- the menu dismisses on any dimension change (see the close effect above), so this open-time window snapshot never goes stale https://github.com/thomasluizon/orbit-ui-mobile/issues/243 const window = Dimensions.get('window') return getAnchoredMenuPosition({ anchorRect: anchorRect ?? getFallbackAnchorRect(window.width), @@ -212,9 +216,8 @@ export function AnchoredMenu({ pointerEvents="none" style={[styles.backdrop, { opacity: backdropOpacity }]} /> - +// react-doctor-disable-next-line only-export-components -- co-located label-resolver helper dedicated to this bar; Fast Refresh dev-only, no runtime effect https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function resolveAppBarRightActionLabel( right: AppBarRightVariant | undefined, rightLabel: string | undefined, diff --git a/apps/mobile/components/ui/app-date-picker.tsx b/apps/mobile/components/ui/app-date-picker.tsx index af7b47d6b..b18015eb1 100644 --- a/apps/mobile/components/ui/app-date-picker.tsx +++ b/apps/mobile/components/ui/app-date-picker.tsx @@ -1,10 +1,11 @@ import { useState, useMemo, useCallback, useEffect } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the dialog transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Modal, View, Text, - TouchableOpacity, + Pressable, StyleSheet, } from 'react-native' import { @@ -61,26 +62,30 @@ function DatePickerMonthNav({ return ( - [ + pickerMode === 'years' ? styles.navHidden : null, + pressed ? { opacity: 0.2 } : null, + ]} > - + {monthLead ? ( {monthLead} ) : null} - (pressed ? { opacity: 0.2 } : undefined)} > {yearLabel} - + - [ + pickerMode === 'years' ? styles.navHidden : null, + pressed ? { opacity: 0.2 } : null, + ]} > - + ) } @@ -161,15 +169,15 @@ function DatePickerBody({ const isCurrentMonth = isSameMonth(day, viewDate) return ( - [ styles.dayCell, isSelected && styles.dayCellSelected, isToday && !isSelected && styles.dayCellToday, + pressed ? { opacity: 0.7 } : null, ]} onPress={() => onSelectDay(day)} - activeOpacity={0.7} accessibilityRole="button" accessibilityState={{ selected: isSelected }} accessibilityLabel={formatLocaleDate(day, locale, { @@ -187,7 +195,7 @@ function DatePickerBody({ > {format(day, 'd')} - + ) })} @@ -223,6 +231,7 @@ export function AppDatePicker({ if (isOpen) setVisible(true) } + // react-doctor-disable-next-line no-event-handler -- mount/exit-animation orchestration: `visible` keeps the Modal mounted through the exit timing driven by the isOpen transition; not a synthetic event handler https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => { if (isOpen) { Animated.timing(progress, { @@ -282,11 +291,13 @@ export function AppDatePicker({ key, label: t(`dates.daysShort.${key}`).charAt(0), })) + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is the extracted profile.weekStartDay and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [weekStartsOn, t]) const calendarDays = useMemo(() => { const calStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn }) return Array.from({ length: 42 }, (_, index) => addDays(calStart, index)) + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn is the extracted profile.weekStartDay and already listed; the analyzer wants the qualified member path but the alias tracks it https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [viewDate, weekStartsOn]) const calendarWeeks = useMemo(() => { @@ -337,10 +348,12 @@ export function AppDatePicker({ return ( <> - [ + styles.trigger, + pressed ? { opacity: 0.7 } : null, + ]} onPress={openPicker} - activeOpacity={0.7} accessibilityLabel={ displayValue ? t('common.selectedDate', { date: displayValue }) @@ -358,7 +371,7 @@ export function AppDatePicker({ {displayValue || placeholder || t('common.selectDate')} - + {visible ? ( - - + ) : null} diff --git a/apps/mobile/components/ui/app-logo.tsx b/apps/mobile/components/ui/app-logo.tsx index 5dde27654..75c20e2a2 100644 --- a/apps/mobile/components/ui/app-logo.tsx +++ b/apps/mobile/components/ui/app-logo.tsx @@ -1,3 +1,4 @@ +// react-doctor-disable-next-line rn-prefer-expo-image -- static bundled asset via require(); no remote fetch or cache benefit from expo-image, so RN Image is the lighter primitive https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { Image, type ImageSourcePropType } from 'react-native' import type { LucideProps } from 'lucide-react-native' diff --git a/apps/mobile/components/ui/app-select.tsx b/apps/mobile/components/ui/app-select.tsx index 04f89d2be..3e0690904 100644 --- a/apps/mobile/components/ui/app-select.tsx +++ b/apps/mobile/components/ui/app-select.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the select transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Modal, Text, - TouchableOpacity, + Pressable, FlatList, StyleSheet, } from 'react-native' @@ -26,6 +27,42 @@ interface AppSelectProps { label?: string } +interface AppSelectOptionRowProps { + option: AppSelectOption + isSelected: boolean + primaryColor: string + styles: ReturnType + onSelect: (value: string) => void +} + +const AppSelectOptionRow = memo(function AppSelectOptionRow({ + option, + isSelected, + primaryColor, + styles, + onSelect, +}: Readonly) { + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed ? { opacity: 0.7 } : null, + ]} + onPress={() => onSelect(option.value)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + > + + {option.label} + + {isSelected ? ( + + ) : null} + + ) +}) + export function AppSelect({ value, onChange, @@ -49,6 +86,7 @@ export function AppSelect({ if (isOpen) setVisible(true) } + // react-doctor-disable-next-line no-event-handler -- mount/exit-animation orchestration: `visible` keeps the Modal mounted through the exit timing driven by the isOpen transition; not a synthetic event handler https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => { if (isOpen) { Animated.timing(progress, { @@ -81,10 +119,26 @@ export function AppSelect({ const selectedOption = options.find((o) => o.value === value) - function handleSelect(optionValue: string) { - onChange(optionValue) - setIsOpen(false) - } + const handleSelect = useCallback( + (optionValue: string) => { + onChange(optionValue) + setIsOpen(false) + }, + [onChange], + ) + + const renderOption = useCallback( + ({ item }: { item: AppSelectOption }) => ( + + ), + [handleSelect, styles, tokens.primary, value], + ) const translateY = progress.interpolate({ inputRange: [0, 1], @@ -97,10 +151,12 @@ export function AppSelect({ return ( <> - [ + styles.trigger, + pressed ? { opacity: 0.7 } : null, + ]} onPress={() => setIsOpen(true)} - activeOpacity={0.7} accessibilityLabel={label} accessibilityRole="button" > @@ -114,7 +170,7 @@ export function AppSelect({ {selectedOption?.label ?? label ?? ''} - + {visible ? ( setIsOpen(false)} > - setIsOpen(false)} importantForAccessibility="no" > @@ -149,36 +204,10 @@ export function AppSelect({ data={options} keyExtractor={(item) => item.value} style={styles.list} - renderItem={({ item }) => { - const isSelected = item.value === value - return ( - handleSelect(item.value)} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - > - - {item.label} - - {isSelected ? ( - - ) : null} - - ) - }} + renderItem={renderOption} /> - + ) : null} diff --git a/apps/mobile/components/ui/app-time-picker.tsx b/apps/mobile/components/ui/app-time-picker.tsx index fcb294d3a..f4e412b2f 100644 --- a/apps/mobile/components/ui/app-time-picker.tsx +++ b/apps/mobile/components/ui/app-time-picker.tsx @@ -1,12 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the dialog transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Modal, Pressable, ScrollView, StyleSheet, Text, - TouchableOpacity, View, type StyleProp, type ViewStyle, @@ -94,22 +94,26 @@ function TimeColumn({ values, selected, formatValue, onSelect, styles }: Readonl showsVerticalScrollIndicator onContentSizeChange={centerSelected} > + {/* react-doctor-disable-next-line rn-no-scrollview-mapped-list -- bounded scroll-wheel column (<=60 items) needing synchronous centering via onContentSizeChange + scrollTo; FlatList virtualization breaks the wheel https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */} {values.map((value) => { const isSelected = value === selected return ( - onSelect(value)} - activeOpacity={0.7} accessibilityRole="button" accessibilityState={{ selected: isSelected }} accessibilityLabel={formatValue(value)} - style={[styles.option, isSelected && styles.optionSelected]} + style={({ pressed }) => [ + styles.option, + isSelected && styles.optionSelected, + pressed ? { opacity: 0.7 } : null, + ]} > {formatValue(value)} - + ) })} @@ -148,6 +152,7 @@ export function AppTimePicker({ if (isOpen) setVisible(true) } + // react-doctor-disable-next-line no-event-handler -- mount/exit-animation orchestration: `visible` keeps the Modal mounted through the exit timing driven by the isOpen transition; not a synthetic event handler https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => { if (isOpen) { Animated.timing(progress, { @@ -258,9 +263,8 @@ export function AppTimePicker({ animationType="none" onRequestClose={closePicker} > - {t('common.selectTime')} - (pressed ? { opacity: 0.2 } : undefined)} > {t('common.done')} - + @@ -347,7 +352,7 @@ export function AppTimePicker({ )} - + ) : null} diff --git a/apps/mobile/components/ui/app-toast.tsx b/apps/mobile/components/ui/app-toast.tsx index 2271fcc7c..e4154d191 100644 --- a/apps/mobile/components/ui/app-toast.tsx +++ b/apps/mobile/components/ui/app-toast.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useCallback } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Dimensions, Pressable, @@ -88,6 +89,7 @@ export function AppToast() { }) }, [clearTimer, dismissToast, opacity, scale, translateY]) + // react-doctor-disable-next-line effect-needs-cleanup -- the effect returns clearTimer, which clears the setTimeout stored in dismissTimerRef (indirected so hideToast can also cancel it); the separate unmount effect below is a belt-and-suspenders guard https://github.com/thomasluizon/orbit-ui-mobile/issues/243 useEffect(() => { if (!currentToast) { clearTimer() diff --git a/apps/mobile/components/ui/astra-avatar.tsx b/apps/mobile/components/ui/astra-avatar.tsx index 97bc4daf1..c7c89c446 100644 --- a/apps/mobile/components/ui/astra-avatar.tsx +++ b/apps/mobile/components/ui/astra-avatar.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react' +// react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver spins the avatar on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 import { Animated, Easing, View, type StyleProp, type ViewStyle } from 'react-native' import Svg, { Circle, Path } from 'react-native-svg' import type { LucideProps } from 'lucide-react-native' diff --git a/apps/mobile/components/ui/confirm-dialog.tsx b/apps/mobile/components/ui/confirm-dialog.tsx index 41d288559..19b1ce3b3 100644 --- a/apps/mobile/components/ui/confirm-dialog.tsx +++ b/apps/mobile/components/ui/confirm-dialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the dialog transform/opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, Modal, Pressable, diff --git a/apps/mobile/components/ui/create-api-key-modal.tsx b/apps/mobile/components/ui/create-api-key-modal.tsx index e841a5cd3..d0b9dfdfe 100644 --- a/apps/mobile/components/ui/create-api-key-modal.tsx +++ b/apps/mobile/components/ui/create-api-key-modal.tsx @@ -155,6 +155,7 @@ function ApiKeyCreateForm({ onSubmit, }: Readonly) { const { t } = useTranslation() + const selectedScopeSet = new Set(selectedScopes) return ( <> @@ -174,7 +175,7 @@ function ApiKeyCreateForm({ {availableScopes.map((scope) => ( onToggleScope(scope.scope)} > {scope.scope} diff --git a/apps/mobile/components/ui/fresh-start-animation.tsx b/apps/mobile/components/ui/fresh-start-animation.tsx index 23ed0383c..3b7340f89 100644 --- a/apps/mobile/components/ui/fresh-start-animation.tsx +++ b/apps/mobile/components/ui/fresh-start-animation.tsx @@ -1,9 +1,10 @@ -import { useMemo, useState, useEffect } from 'react' +import { useEffect, useEffectEvent, useMemo, useState } from 'react' import { AccessibilityInfo, View, Text, Modal, + // react-doctor-disable-next-line rn-prefer-reanimated -- RN Animated with useNativeDriver drives the entrance/exit transforms & opacity on the UI thread already; Reanimated 4.x migration deferred (worklets 0.10.0 ABI-pinned to the SDK 57 set, needs on-device QA) https://github.com/thomasluizon/orbit-ui-mobile/issues/243 Animated, StyleSheet, } from 'react-native' @@ -38,6 +39,11 @@ export function FreshStartAnimation({ onComplete }: Readonly new Animated.Value(20), []) const styles = useMemo(() => createStyles(tokens), [tokens]) + const handleComplete = useEffectEvent(() => { + setVisible(false) + onComplete() + }) + useEffect(() => { const easeOut = toAnimatedEasing(easings.out) let cancelled = false @@ -134,8 +140,7 @@ export function FreshStartAnimation({ onComplete }: Readonly { - setVisible(false) - onComplete() + handleComplete() }, 2500) return () => { @@ -143,7 +148,7 @@ export function FreshStartAnimation({ onComplete }: Readonly {segments.map((seg, i) => seg.isMatch ? ( + // react-doctor-disable-next-line no-array-index-as-key -- static positional split of a fixed string; segments never reorder or filter https://github.com/thomasluizon/orbit-ui-mobile/issues/243 ) : ( + // react-doctor-disable-next-line no-array-index-as-key -- static positional split of a fixed string; segments never reorder or filter https://github.com/thomasluizon/orbit-ui-mobile/issues/243 {seg.text} ), )} diff --git a/apps/mobile/components/ui/keyboard-aware-scroll-view.tsx b/apps/mobile/components/ui/keyboard-aware-scroll-view.tsx index 3f7c68fe9..a297b4bac 100644 --- a/apps/mobile/components/ui/keyboard-aware-scroll-view.tsx +++ b/apps/mobile/components/ui/keyboard-aware-scroll-view.tsx @@ -227,6 +227,7 @@ function useKeyboardAwareContextValue( }, ) => { keyboardVisibleRef.current = true + // react-doctor-disable-next-line rn-no-dimensions-get -- one-shot window-height read inside the keyboard-show handler to compute the keyboard top as a fallback when the event omits screenY; not a persisted layout dimension https://github.com/thomasluizon/orbit-ui-mobile/issues/243 const screenHeight = Dimensions.get('window').height const keyboardTop = event?.endCoordinates?.screenY ?? diff --git a/apps/mobile/components/ui/offline-unavailable-state.tsx b/apps/mobile/components/ui/offline-unavailable-state.tsx index 0b2735e54..cb35fa488 100644 --- a/apps/mobile/components/ui/offline-unavailable-state.tsx +++ b/apps/mobile/components/ui/offline-unavailable-state.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { WifiOff } from 'lucide-react-native' import { createTokensV2, radius, type AppTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -47,10 +47,13 @@ export function OfflineUnavailableState({ {title} {description} {actionLabel && onAction ? ( - [ + styles.button, + disabled ? styles.buttonDisabled : null, + pressed && !disabled ? { opacity: 0.85 } : null, + ]} onPress={onAction} - activeOpacity={0.85} disabled={disabled} accessibilityRole="button" accessibilityLabel={actionLabel} @@ -62,7 +65,7 @@ export function OfflineUnavailableState({ accessibilityState={{ disabled }} > {actionLabel} - + ) : null} diff --git a/apps/mobile/components/ui/pill-button.tsx b/apps/mobile/components/ui/pill-button.tsx index 94023fe74..90899d151 100644 --- a/apps/mobile/components/ui/pill-button.tsx +++ b/apps/mobile/components/ui/pill-button.tsx @@ -109,6 +109,7 @@ export function PillButton({ leading )} {hasLabel && + // react-doctor-disable-next-line no-polymorphic-children -- deliberate label-or-node API: a string/number child renders as the themed label, any other node renders as-is; matches the web PillButton contract https://github.com/thomasluizon/orbit-ui-mobile/issues/243 (typeof children === 'string' || typeof children === 'number' ? ( + {/* react-doctor-disable-next-line rn-no-scrollview-mapped-list -- bounded year-range grid with programmatic scroll-to-selection via scrollTo(row); FlatList virtualization breaks the row-offset centering https://github.com/thomasluizon/orbit-ui-mobile/issues/243 */} {years.map((year) => { const isSelected = year === selectedYear return (