From 47aab541b1be130f43a48e1c88ccf1895e4422cd Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 13 Jul 2026 20:12:47 -0300 Subject: [PATCH 1/2] chore(ui): drive React Doctor to zero in web components/hooks (#243) React Doctor burn-down for bucket B2 (apps/web/components/** except habits/ & ui/, plus apps/web/hooks/**): 105 findings driven to 0. Fixed properly: - effect-needs-cleanup (6): gamification celebration/toast timers now track nested timers in the same ref and return a cleanup from the timer-creating effect (removed redundant unmount-only effects). - prefer-use-effect-event (3): wrapped changing prop callbacks in useEffectEvent so effects no longer re-subscribe on parent redraws. - use-lazy-motion (7): swapped motion -> m + ; domAnimation everywhere except route-transition-shell (popLayout needs domMax). - no-inline-exhaustive-style (13): hoisted static style objects to module-scope CSSProperties consts. - no-tiny-text (16 + extras): bumped sub-12px text to the DESIGN.md 12px type-role floor (truncate/ellipsis contexts, so no overflow). - only-export-components (4): removed unused exports; extracted the shared toSectionStatus/SectionStatus and trendHeadline helpers into insights-section-status.ts and insights-headline-model.ts. - no-giant-component (1): extracted NotificationRow from NotificationBell. - no-prop-callback-in-render (3): goal-drawer deep-link actions moved from render into a single guarded effect. - no-impure-state-updater (1): login resend countdown side effects moved out of the setState updater into the interval callback (ref-driven). - query-mutation-missing-invalidation (1): marketing consent mutation now reconciles via onSettled invalidate. - js-combine-iterations (3), js-hoist-intl (2), no-array-index-as-key (2), button-has-type (1), control-has-associated-label (1), rerender-lazy-state-init (1), rerender-state-only-in-handlers (1), prefer-module-scope-static-value (1), client-localstorage-no-version (1), no-locale-format-in-render (1, via shared useDateFormat). Justified suppressions (react-doctor-disable-next-line + #243 WHY URL, only where the rule is a genuine false positive or deliberate choice): - exhaustive-deps (14): deps already track query.data/profile.* via an alias react-doctor does not resolve; adding the raw member expression is semantically identical and uglier. - no-unguarded-browser-global (4): createPortal/localStorage reached only behind a client-only render gate (useIsClient / viewport / the repo's canonical 'localStorage' in globalThis guard). - query-mutation-missing-invalidation (2): report-user and suggest-tags mutate no client-cached data. - prefer-html-dialog (2), no-large-animated-blur (2): desktop-only non-modal rail / FAB; native dialog and phone GPU concerns don't apply. - no-many-boolean-props (1): four orthogonal range-picker state flags; effect-needs-cleanup (1): tour observer lifecycle is ref-managed; nextjs-no-client-side-redirect (1): tour orchestrator navigation; no-pass-data/live-state-to-parent (2): reusable code-entry primitive. Refs thomasluizon/orbit-ui-mobile#243 (React Doctor burn-down: web components/hooks) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../marketing-consent-prompt.test.tsx | 20 +- .../components/advanced/advanced-sections.tsx | 6 +- .../calendar/calendar-agenda-view.tsx | 6 +- .../calendar/calendar-day-detail.tsx | 2 +- .../web/components/calendar/calendar-grid.tsx | 4 +- .../calendar/calendar-range-view.tsx | 1 + .../calendar/calendar-time-grid.tsx | 14 +- apps/web/components/charts/bar-chart.tsx | 4 +- .../components/charts/multi-month-heatmap.tsx | 6 +- .../chat/breakdown-frequency-picker.tsx | 1 + .../components/chat/breakdown-suggestion.tsx | 42 ++-- .../chat/pending-operation-card.tsx | 7 +- .../components/command/command-palette.tsx | 109 +++++----- .../gamification/achievement-toast.tsx | 16 +- .../gamification/all-done-celebration.tsx | 46 ++--- .../goal-completed-celebration.tsx | 46 ++--- .../gamification/level-up-overlay.tsx | 46 +++-- .../gamification/streak-celebration.tsx | 51 +++-- .../streak-freeze-celebration.tsx | 39 ++-- .../gamification/welcome-back-toast.tsx | 61 +++--- .../create-goal-modal/goal-type-selector.tsx | 4 +- .../components/goals/goal-ask-astra-row.tsx | 2 +- .../use-goal-drawer-initial-action.ts | 42 ++-- .../use-goal-status-actions.ts | 12 +- .../components/goals/goal-detail-sections.tsx | 2 +- .../components/goals/goal-status-badge.tsx | 4 +- .../achievements-timeline-section.tsx | 17 +- .../insights/completion-trends-section.tsx | 3 +- .../insights/goal-progress-section.tsx | 3 +- .../insights/insights-headline-model.ts | 32 +++ .../components/insights/insights-headline.tsx | 34 +--- .../insights/insights-section-status.ts | 16 ++ .../components/insights/insights-section.tsx | 18 +- .../insights/monthly-heatmap-section.tsx | 3 +- .../multi-habit-comparison-section.tsx | 3 +- .../insights/streak-history-section.tsx | 6 +- .../insights/xp-over-time-section.tsx | 6 +- .../marketing-consent-prompt.tsx | 131 ++++++------ .../milestone-share/milestone-share-card.tsx | 30 +-- .../motion/route-transition-shell.tsx | 74 +++---- .../components/navigation/bottom-tab-bar.tsx | 2 +- .../navigation/notification-bell.tsx | 187 ++++++++++-------- .../onboarding/onboarding-complete.tsx | 55 +++--- .../onboarding/onboarding-create-habit.tsx | 8 +- .../components/onboarding/onboarding-flow.tsx | 3 +- .../profile/public-profile-view.tsx | 22 ++- .../components/referral/referral-prompt.tsx | 126 ++++++------ .../settings/public-profile-settings.tsx | 26 +-- .../web/components/share/share-card-sheet.tsx | 14 +- apps/web/components/share/share-card.tsx | 55 +++--- apps/web/components/shell/app-shell.tsx | 1 + .../components/shell/astra-copilot-rail.tsx | 6 +- apps/web/components/shell/rail-drawer.tsx | 87 ++++---- apps/web/components/shell/today-rail.tsx | 1 + apps/web/components/shell/topbar-slot.tsx | 1 + apps/web/components/tour/tour-overlay.tsx | 2 +- apps/web/components/tour/tour-provider.tsx | 4 + .../web/components/tour/tour-replay-modal.tsx | 2 +- apps/web/components/tour/tour-spotlight.tsx | 1 + apps/web/components/tour/tour-tooltip.tsx | 8 +- .../components/upgrade/billing-dashboard.tsx | 2 +- .../upgrade/plan-comparison-cards.tsx | 2 +- apps/web/hooks/use-bulk-actions.ts | 12 +- apps/web/hooks/use-calendars.ts | 10 +- apps/web/hooks/use-chat-composer.ts | 2 + apps/web/hooks/use-friends.ts | 1 + apps/web/hooks/use-gamification.ts | 3 + apps/web/hooks/use-login-code-entry.ts | 18 +- apps/web/hooks/use-profile.ts | 2 + apps/web/hooks/use-referral.ts | 1 + apps/web/hooks/use-tags.ts | 1 + apps/web/hooks/use-wrapped.ts | 2 + 72 files changed, 881 insertions(+), 755 deletions(-) create mode 100644 apps/web/components/insights/insights-headline-model.ts create mode 100644 apps/web/components/insights/insights-section-status.ts diff --git a/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx index d639ed183..95d45f216 100644 --- a/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx +++ b/apps/web/__tests__/components/marketing-consent/marketing-consent-prompt.test.tsx @@ -7,8 +7,8 @@ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })) -vi.mock('motion/react', () => ({ - motion: new Proxy( +vi.mock('motion/react', () => { + const proxy = new Proxy( {}, { get: @@ -16,9 +16,16 @@ vi.mock('motion/react', () => ({ ({ children, ...rest }: { children?: React.ReactNode }) => React.createElement(tag, rest, children), }, - ), - useReducedMotion: () => true, -})) + ) + return { + motion: proxy, + m: proxy, + LazyMotion: ({ children }: { children?: React.ReactNode }) => children, + domAnimation: {}, + domMax: {}, + useReducedMotion: () => true, + } +}) vi.mock('@/components/ui/app-overlay', () => ({ AppOverlay: ({ @@ -40,11 +47,12 @@ vi.mock('@/components/ui/app-overlay', () => ({ })) const patchProfile = vi.fn() +const invalidate = vi.fn() let profileValue: { marketingEmailConsent: boolean | null } | undefined = { marketingEmailConsent: null, } vi.mock('@/hooks/use-profile', () => ({ - useProfile: () => ({ profile: profileValue, patchProfile }), + useProfile: () => ({ profile: profileValue, patchProfile, invalidate }), })) const updateMarketingConsent = vi.fn().mockResolvedValue(undefined) diff --git a/apps/web/components/advanced/advanced-sections.tsx b/apps/web/components/advanced/advanced-sections.tsx index de3c8626f..17ee7e9e0 100644 --- a/apps/web/components/advanced/advanced-sections.tsx +++ b/apps/web/components/advanced/advanced-sections.tsx @@ -32,7 +32,7 @@ interface QueryStatus { /** Writes to the clipboard and reports whether the write succeeded, so callers * only show "Copied!" on success. */ -export async function copyToClipboard(text: string): Promise { +async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text) return true @@ -196,7 +196,7 @@ export function ApiKeyCard({ style={{ marginTop: 4, fontFamily: 'var(--font-mono)', - fontSize: 11, + fontSize: 12, letterSpacing: '0.02em', color: 'var(--fg-3)', }} @@ -208,7 +208,7 @@ export function ApiKeyCard({ className="shrink-0 text-right" style={{ fontFamily: 'var(--font-mono)', - fontSize: 11, + fontSize: 12, lineHeight: 1.6, letterSpacing: '0.02em', color: 'var(--fg-3)', diff --git a/apps/web/components/calendar/calendar-agenda-view.tsx b/apps/web/components/calendar/calendar-agenda-view.tsx index c9e0fa1be..2c1b0f086 100644 --- a/apps/web/components/calendar/calendar-agenda-view.tsx +++ b/apps/web/components/calendar/calendar-agenda-view.tsx @@ -270,7 +270,7 @@ function AgendaEventBlock({ block, displayTime }: Readonly(() => { @@ -88,6 +89,7 @@ export function CalendarGrid({ completionRatio: totalCount > 0 ? completedCount / totalCount : 0, } }) + // react-doctor-disable-next-line exhaustive-deps -- weekStartsOn aliases profile.weekStartDay and is already in deps; react-doctor does not resolve the alias; https://github.com/thomasluizon/orbit-ui-mobile/issues/243 }, [currentMonth, dayMap, weekStartsOn]) return ( @@ -116,7 +118,7 @@ export function CalendarGrid({ className="text-center uppercase" style={{ fontFamily: 'var(--font-mono)', - fontSize: 11, + fontSize: 12, fontWeight: 500, color: 'var(--fg-3)', letterSpacing: '0.04em', diff --git a/apps/web/components/calendar/calendar-range-view.tsx b/apps/web/components/calendar/calendar-range-view.tsx index 41eecea58..c5eb3220c 100644 --- a/apps/web/components/calendar/calendar-range-view.tsx +++ b/apps/web/components/calendar/calendar-range-view.tsx @@ -38,6 +38,7 @@ interface CalendarRangeViewProps { /** Custom-range view: a mini-calendar to pick a contiguous range, then the same * time grid rendered with one column per day in that range. */ +// react-doctor-disable-next-line no-many-boolean-props -- the four flags model orthogonal, independent states (clamp notice, mid-pick hint, range loading, recurring-row toggle), not a combinatorial variant space; they are threaded from the calendar page (owned by a separate bucket), so collapsing the API is out of scope here; https://github.com/thomasluizon/orbit-ui-mobile/issues/243 export function CalendarRangeView({ currentMonth, monthDayMap, diff --git a/apps/web/components/calendar/calendar-time-grid.tsx b/apps/web/components/calendar/calendar-time-grid.tsx index d3e41b3e7..6bf70b8b6 100644 --- a/apps/web/components/calendar/calendar-time-grid.tsx +++ b/apps/web/components/calendar/calendar-time-grid.tsx @@ -168,7 +168,7 @@ function TimedBlock({ className="truncate" style={{ fontFamily: 'var(--font-sans)', - fontSize: 11, + fontSize: 12, fontWeight: 500, lineHeight: 1.2, color: completed ? 'var(--fg-3)' : 'var(--fg-1)', @@ -182,7 +182,7 @@ function TimedBlock({ className="truncate" style={{ fontFamily: 'var(--font-mono)', - fontSize: 10, + fontSize: 12, color: 'var(--fg-3)', fontVariantNumeric: 'tabular-nums', }} @@ -217,7 +217,7 @@ function AllDayChip({ entry }: Readonly<{ entry: CalendarDayEntry }>) { className="truncate" style={{ fontFamily: 'var(--font-sans)', - fontSize: 11, + fontSize: 12, fontWeight: 500, color: completed ? 'var(--fg-3)' : 'var(--fg-1)', textDecoration: completed ? 'line-through' : 'none', @@ -253,7 +253,7 @@ function AllDayMoreChip({ - {bars.map((bar, index) => { + {bars.map((bar) => { const ratio = max > 0 ? bar.value / max : 0 return ( -
  • +
  • ) { const locale = useLocale() + const monthFormatter = useMemo( + () => new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' }), + [locale], + ) const parsed = days .map((day) => { @@ -128,7 +133,6 @@ export function MultiMonthHeatmap({ const width = columns * (cellSize + cellGap) - cellGap const height = MONTH_LABEL_HEIGHT + ROWS * (cellSize + cellGap) - cellGap - const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' }) const labels = monthLabelColumns(cells) const fillFor = (value: number) => diff --git a/apps/web/components/chat/breakdown-frequency-picker.tsx b/apps/web/components/chat/breakdown-frequency-picker.tsx index 7c473c509..2e14b9986 100644 --- a/apps/web/components/chat/breakdown-frequency-picker.tsx +++ b/apps/web/components/chat/breakdown-frequency-picker.tsx @@ -30,6 +30,7 @@ export function BreakdownFrequencyPicker({